A Guide to Using Nested Routes in Ruby

A Guide to Using Nested Routes in Ruby

One of the reasons why Ruby on Rails is considered a strong contender in the world of web development and is largely favored by startups worldwide is because it is highly time-efficient. One of the aspects that makes Ruby easy to use is its super-simple routing setup. Creating resources, scaffolding CRUD operations, nesting resources, and scaling up data schema is a breeze when using Ruby. 

In web applications, nested routes are known for being difficult to maintain, as each level of nesting multiplies the complexity associated with accessing the resources. In this blog, we take a look at how Ruby simplifies the system of creating and managing routes and resources.

Here’s what we’ll cover in the tutorial:

What is Routing in Ruby?

Routing is a technique by which applications redirect incoming traffic to the correct controllers and handlers. It is used widely by applications to define multiple rules to organize how data is queried and navigated inside their applications. Advanced frameworks such as Ruby are also quite proficient at controlling access to your web application’s resources.

What are Resources?

Since this term is mentioned so often, it’s important to first understand what a resource represents. Any object that can be accessed via a URI (Uniform Resource Index) and is expected to take in CRUD operations via users is referred to as a resource. In the case of Ruby, it is generally a database table, which can be represented by a model, and accessed by a controller.

For instance, a typical Post resource in a Ruby application would be linked with a posts table in the database. It would also have a controller mapped to it, namely posts_controller by map.resources :posts. This would generate routes like /posts (a collection of Post resources) and /posts/3423 (a specific Post resource).

The Routing File

routes.rb is the file that takes care of all the routing in your application by charting out how resources are mapped to the available routes. 

If we were to route the Post resource that we had created in an earlier section, the routes.rb file would look like this: 

Rails.application.routes.draw do
resources: posts
end

Now, this is quite compact and convenient. But let's visualize what a single line of resources: posts generates for us in the background:

HTTP Verb

Path

Controller#Action

Used for

GET

/posts

posts#index

display a list of all posts

GET

/posts/new

posts#new

return an HTML form for creating a new post

POST

/posts

posts#create

create a new post

GET

/posts/:id

posts#show

display a specific post

GET

/posts/:id/edit

posts#edit

return an HTML form for editing a post

PATCH/PUT

/posts/:id

posts#update

update a specific post

DELETE

/posts/:id

posts#destroy

delete a specific post

This is one of the biggest reasons why Ruby is favored in fast-paced startups and companies. However, this is just a small example of how Ruby makes things easier for developers. Now let’s address the real topic this article is focused on — the nested routes.

Routing for Nested Resources

As we have seen previously, routing a resource to CRUD operations only takes adding two words to the routing directory. But in real-life projects, singular independent resources are not widely used. Rather, a collection of inter-dependent models together make up an application. This can involve relations between the models, a prominent one of which is nesting. Nesting refers to a belongs_to or has_many relation between two models. For example, in our Posts model, each post can have any number of comments. Those comments can not be an independent data point as they would hold no meaning without the post they are meant for.

Such models, which are entirely dependent on other models, are best placed nested inside their parent models. In the following section, we take a look at how we can build upon our previous example of Post and add a nested resource Comment to it.

How to Use Nested Routes

Setting up a nested resource is a pretty simple task — all you have to do is add a nested route to your routes.rb file. In our case, the routes file would look like:

Rails.application.routes.draw do
resources: posts do
resources: comments
end
end

But in real life, that’s not all. There are a lot of underlying changes that need to be made to create the parent and child models. Let's take a look at all of that, step-by-step.

Step 0: [OPTIONAL] Create a Project

Note that if you’re trying to create a nested relation between two resources present in a project, you can safely skip steps 1 & 2 and start with step 3.

If you’re trying this out for the very first time, you’re going to need a Ruby on Rails project to work on. The prerequisites installations for initializing a project are outlined here:

  1. The Ruby language.
  2. The correct version of Development Kit, if you are using Windows.
  3. A working installation of the SQLite3 Database.

Once you have these installed, you can create a new Ruby on Rails project for yourself by running this command on the terminal: 

rails new blog

This will create a new project called blog, and will dump something similar to this in the output when done:

    create 
    . . .
    create  Gemfile
    . . .
      create  app
      . . .
      create  app/controllers/application_controller.rb
      . . .
      create  app/models/application_record.rb
      . . .
      create  app/views/layouts/application.html.erb
      . . .
      create  config
      create  config/routes.rb
      create  config/application.rb
      . . .
      create  config/environments
      create  config/environments/development.rb
      create  config/environments/production.rb
      create  config/environments/test.rb
      . . .
      create  config/database.yml
      create  db
      create  db/seeds.rb
      . . .
        run  bundle install
      . . .
Bundle complete! 18 Gemfile dependencies, 78 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
      . . .
* bin/rake: Spring inserted
* bin/rails: Spring inserted

Next, change your terminal directory to your project’s root to ensure that you run the next commands inside of your project.

cd blog

We are ready to start with the tutorial now! 

Step 1: Scaffold the Two Resources

Once you have the prerequisites fulfilled and a new Rails project created, run the following in your terminal to create the Post resource:

rails generate scaffold Post content:text name:string

This will generate the following model for you in app/models/post.rb:

class Post < ActiveRecord::Base
  attr_accessible :content, :name
end

Next, create the Comment resource by running the following command:

rails generate scaffold Comment content:text user:string

This will generate the following model for you in app/models/comments.rb:

class Comment < ActiveRecord::Base
  attr_accessible :content, :user
end

We now have two resources in our project! The next step is where the actual nested connection is made between the Post and the Comment resource.

Step 2: Place the Nested Relation in the Two Models

Update the database migration file for Post by opening db/migrate/20201114212509_create_posts.rb in an editor (the filename will vary in your case based on your timestamp). 

This is what the file contains:

class CreatePosts < ActiveRecord::Migration[5.2]
  def change
    create_table :posts do |t|
      t.text :body

      t.timestamps
    end
  end
end

You need to update this file and explicitly specify the foreign key that references your nested resource, i.e. comment. The updated file will look like:

class CreatePosts < ActiveRecord::Migration[5.2]
  def change
    create_table :posts do |t|
      t.text :body
      t.references :comment, foreign_key: true

      t.timestamps
    end
  end
end

The highlighted line makes sure that whenever the Posts table is created, it contains a field that holds references to its nested comments.

Now let’s edit the Comment model to specify the child relation. Add the highlighted line in app/models/comment.rb:

class Comment < ApplicationRecord
  attr_accessible :content, :user
  belongs_to :post
end

This makes Comment a child of Post. Now the only thing left is to specify the parent relation in the Post model.

We can edit app/models/post.rb and add the following highlighted line:

class Post < ActiveRecord::Base
  attr_accessible :content, :name
  has_many :comments
end

This allows a Post to have any number of comments as it’s children.

One important thing to note here is that in a parent-child relation, there is a high probability that a parent might get destroyed by some routine event in the app. In this case, it is important to safely destroy all its children too. This is so because if not destroyed, the belongs_to reference in the child object (Comment) would lead to a null parent (Post) object, which can lead to undesired behavior. 

Thankfully, Ruby has a nice little solution for this scenario as well:

class Post < ActiveRecord::Base
  attr_accessible :content, :name
  has_many :comments, dependent: :destroy
end

The highlighted part of the model guides it to destroy its dependents (in our case, it’s comments) when it is destroyed. This helps to keep ghost references out of our database.

Step 3: Add the Nested Route to the Routes File

Now, as the final step, the routes.rb file needs to be updated to list comment as a nested child of post. The routes.rb file initially looked like this after creating the two models:

Rails.application.routes.draw do
resources: posts
resources: comments
end

To define comments as a nested route under posts, make the following change:

Rails.application.routes.draw do
resources: posts do
            resources: comments
      end
end

You now have a nested comments route under posts! But is this all? 

No, the controllers and the views too need to be informed about the changes that we just made.

Step 4: Update the Controllers and Views

If you open the controller file at app/controllers/comments_controller.rb, this is what it looks like:

class CommentsController < ApplicationController
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  # GET /comments
  # GET /comments.json
  def index
    @comments = Comment.all
  end

  # GET /comments/1
  # GET /comments/1.json
  def show
  end

  # GET /comments/new
  def new
    @comment = Comment.new
  end

  # GET /comments/1/edit
  def edit
  end

  # POST /comments
  # POST /comments.json
  def create
    @comment = Comment.new(comment_params)

    respond_to do |format|
      if @comment.save
        format.html { redirect_to @comment, notice: 'Comment was successfully created.' }
        format.json { render :show, status: :created, location: @comment }
      else
        format.html { render :new }
        format.json { render json: @comment.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /comments/1
  # PATCH/PUT /comments/1.json
  def update
    respond_to do |format|
      if @comment.update(comment_params)
        format.html { redirect_to @comment, notice: 'Comment was successfully updated.' }
        format.json { render :show, status: :ok, location: @comment }
      else
        format.html { render :edit }
        format.json { render json: @comment.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /comments/1
  # DELETE /comments/1.json
  def destroy
    @comment.destroy
    respond_to do |format|
      format.html { redirect_to comments_url, notice: 'Comment was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_comment
      @comment = Comment.find(params[:id])
    end

end

Currently, the controller is designed to work with a single, independent model. However, to ensure proper access to nested resources, it is crucial to update the controller to support the same. Also, comments being created directly would not make much sense, as each comment is bound to be in the context of a post. So it is even more logical to remove direct access to creating new comments and enforce the presence of a post in all situations. Here’s how we can achieve that:

Add the following code above to all private declarations at the end of the file:

def get_post
  @post = Post.find(params[:post_id])
end

Next, make sure that the above-defined action is run before every other action in the controller by adding this at the top of the file:

class CommentsController < ApplicationController
  before_action :get_post

Now, the @post reference that we just defined can be used to redefine the index method, so that instead of listing all comments, we only list comments relevant to the specific @post. For this, let’s modify the index action to look like this:

def index
  @comments = @post.comments
end

This completes our index update. Next, let us update the new method, to associate each new comment with a post every time:

def new
  @comment = @post.comments.build
end

The closest action to new is create. Update the create method to make it look like this:

def create
  @comment = @post.comments.build(comment_params)

      respond_to do |format|
      if @comment.save
          format.html { redirect_to post_comments_path(@post), notice: 'Comment was successfully created.' }
          format.json { render :show, status: :created, location: @comment }
      else
          format.html { render :new }
          format.json { render json: @comment.errors, status: :unprocessable_entity }
    end
  end
end

Next, let's take a look at the set_comment method. Instead of looking for a particular instance from the entire Comment class by id, we need to look for a matching id in the collection of comments only associated with a particular post. Here's how we can achieve that:

def set_comment
  @comment = @post.comments.find(params[:id])
end

With set_comment in place, now we can fix two more actions – update and destroy.

Most of the logic in update and destroy is pretty accurate. The only change needed here is to make sure that the redirection that happens on a successful update/destroy action is to the correct comment object. Here's how that can be fixed in the update action:

def update
    respond_to do |format|
      if @comment.update(comment_params)
        format.html { redirect_to post_comment_path(@post), notice: 'Comment was successfully updated.' }
        format.json { render :show, status: :ok, location: @comment }
      else
        format.html { render :edit }
        format.json { render json: @comment.errors, status: :unprocessable_entity }
      end
    end
end

And the destroy action:

def destroy
    @comment.destroy
    respond_to do |format|
      format.html { redirect_to post_comments_path(@post), notice: 'Comment was successfully destroyed.' }
      format.json { head :no_content }
  end
end

Finally, we are done updating the comments controller. Here's what the file will look like after the changes are made:

class CommentsController < ApplicationController
  before_action :get_post
  before_action :set_comment, only: [:show, :edit, :update, :destroy]

  # GET /comments
  # GET /comments.json
  def index
    @comments = @post.comments
  end

  # GET /comments/1
  # GET /comments/1.json
  def show
  end

  # GET /comments/new
  def new
    @comment = @post.comments.build
  end

  # GET /comments/1/edit
  def edit
  end

  # POST /comments
  # POST /comments.json
  def create
    @comment = @post.comments.build(comment_params)

        respond_to do |format|
        if @comment.save
            format.html { redirect_to post_comments_path(@post), notice: ’Comment was successfully created.' }
            format.json { render :show, status: :created, location: @comment }
        else
            format.html { render :new }
            format.json { render json: @post.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /comments/1
  # PATCH/PUT /comments/1.json
  def update
    respond_to do |format|
      if @comment.update(comment_params)
        format.html { redirect_to post_comment_path(@post), notice: 'Comment was successfully updated.' }
        format.json { render :show, status: :ok, location: @comment }
      else
        format.html { render :edit }
        format.json { render json: @comment.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /comments/1
  # DELETE /comments/1.json
  def destroy
    @comment.destroy
    respond_to do |format|
      format.html { redirect_to post_comments_path(@post), notice: 'Comment was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private

  def get_post
    @post = Post.find(params[:post_id])
  end
    # Use callbacks to share common setup or constraints between actions.
    def set_comment
      @comment = @post.comments.find(params[:id])
    end
end

That’s it! 

We have successfully defined a nested resource in our Ruby on Rails project, and also configured the nested resource’s controller to allow contextual access to the resource’s methods. This completes the entire functionality behind the nested routing. The ideal next task would be to configure the views properly, to make sure that the correct resources are exposed to the users of the application. View changes are highly relevant to individual projects, which is why they are out of the scope of this discussion. However, if you need a general outline of what things you need to cover when setting up views for nested resources, you can follow these steps:

This finishes our journey of adding nested resources inside our Ruby on Rails application. But hold on! Managing nested resources is not always as simple as it seems. There are a lot of things that need to be taken care of to ensure that the performance of an application with nested resources doesn’t get affected.

Best Practices for Using Nested Routes in Ruby

It turns out there are many aspects of working with nested routes that can be easily optimized. Let’s take a look at some of them.

Avoid Unnecessary Deep Nesting

While it is extremely simple to nest one resource inside another to create a chained URL (similar to /posts/:post_id/comments/:comment_id), it can get tempting to nest another resource, say reply, inside comments directly. The routes.rb, in that case, would look like this:

Rails.application.routes.draw do
map.resources: posts do
            post.resources: comments do
comment.resources: replies
            end
      end
end

But this architecture has a serious issue with itself. The URL for accessing a reply now becomes /posts/:post_id/comments/:comment_id/reply/:reply_id. This is a perfect example of a code smell. Managing such resources can become a tedious task, even though we are only 3-levels deep now. Needless to say, each level of nesting adds another variable to the resource URL, and makes it exceedingly complex.

A good solution for this would be to define each of the nested relations separately, instead of chaining all of them together at once. In our case, the routes.rb file would look like:

Rails.application.routes.draw do
map.resources: posts do
post.resources: comments
end
      map.resources: comments do
comment.resources: replies
end
end

The above code makes sure that you generate simpler routes for your resources, such as /post/:post_id/comment/:comment_id for comments, and /comment/:comment_id/reply/:reply_id for replies. This might seem like a shift from 3 variables to 2, but it can be really helpful in cases with nesting that is 4 or 5-levels deep (eg. when adding likes to replies). However, it turns out that there’s another best practice that can be followed to make deeper nested routings more simplified.

Use Shallow Routing

While manually extracting the relations inside routes.rb file would seem to be a more descriptive alternative, another way to go about it is using shallow routes. Ruby allows routes to be defined as shallow, making sure that their generated URLs can not go more than 2-levels deep. Here’s how you can use it:

Rails.application.routes.draw do
      map.resources :posts, :shallow => true do
            post.resources :comments do
                  comment.resources :replies
            end
      end
end

This does the exact same thing that we previously did – reducing the URLs to a maximum of 2-levels in depth. It also brings in a few more things with it, but first, let's analyze the routes this generates:

posts GET    /posts(.:format)                                  {:action=>"index", :controller=>"posts"}
                     POST   /posts(.:format)                                  {:action=>"create", :controller=>"posts"}
            new_post GET    /posts/new(.:format)                              {:action=>"new", :controller=>"posts"}
           edit_post GET    /posts/:id/edit(.:format)                         {:action=>"edit", :controller=>"posts"}
                post GET    /posts/:id(.:format)                              {:action=>"show", :controller=>"posts"}
                     PUT    /posts/:id(.:format)                              {:action=>"update", :controller=>"posts"}
                     DELETE /posts/:id(.:format)                              {:action=>"destroy", :controller=>"posts"}
       post_comments GET    /posts/:post_id/comments(.:format)                {:action=>"index", :controller=>"comments"}
                     POST   /posts/:post_id/comments(.:format)                {:action=>"create", :controller=>"comments"}
    new_post_comment GET    /posts/:post_id/comments/new(.:format)            {:action=>"new", :controller=>"comments"}
        edit_comment GET    /comments/:id/edit(.:format)                      {:action=>"edit", :controller=>"comments"}
             comment GET    /comments/:id(.:format)                           {:action=>"show", :controller=>"comments"}
                     PUT    /comments/:id(.:format)                           {:action=>"update", :controller=>"comments"}
                     DELETE /comments/:id(.:format)                           {:action=>"destroy", :controller=>"comments"}
   comment_replies GET    /comments/:comment_id/replies(.:format)         {:action=>"index", :controller=>"replies"}
                     POST   /comments/:comment_id/replies(.:format)         {:action=>"create", :controller=>"replies"}
new_comment_reply  GET    /comments/:comment_id/replies/new(.:format)     {:action=>"new", :controller=>"replies"}
       edit_reply  GET    /replies/:id/edit(.:format)                     {:action=>"edit", :controller=>"replies"}
            reply GET    /replies/:id(.:format)                          {:action=>"show", :controller=>"replies"}
                     PUT    /replies/:id(.:format)                          {:action=>"update", :controller=>"replies"}
                     DELETE /replies/:id(.:format)                          {:action=>"destroy", :controller=>"replies"}

Shallow routing adds a level of simplification with itself. As you can see, /posts/:post_id/comments/new is available, as expected, but /posts/:post_id/comments/:comment_id/edit is missing, and instead, /comments/:comment_id/edit is available. In reality, this is an optimization that shallow routing does for you. This is because, while editing a comment, it’s parent post’s identifier is not quite relevant, as the comment can be directly referenced via its own id, which is unique even in the pool of all comments from all posts in the comments table.

This was just one optimization that we observed. Overall, shallow routes separate the available operations (show, edit, update, destroy, index, new, and create) into two groups, available at the two levels of nesting – base level or the parent level (show, edit, update, destroy, and the nested level or the child level (index, new and create). This further simplifies our routes and removes unnecessary chaining between resources.

Restrict Auto-Generated Routes

While this is a good practice to follow when creating single level routes, it becomes much more crucial when working with nested routes, as each nested route multiplies the number of generated routes and therefore, results in a very long list of available routes. But if you have prior knowledge of relevant routes for your use case, you can restrict the others from being generated, thereby reducing a lot of clutter in your application. 

Let's again pull up a list of routes that are generated for a resource created without any restrictions: 

posts         GET       /posts(.:format)
              POST      /posts(.:format)
new_post      GET       /posts/new(.:format)
edit_post     GET       /posts/:id/edit(.:format)
post          GET       /posts/:id(.:format)
              PUT       /posts/:id(.:format)
              DELETE    /posts/:id(.:format)

Note that two of these are auto-generated forms by Ruby, to take in model input conveniently. While this is a great feature to build out apps quickly, you'd often not want objects to be created/edited directly by a pre-generated form. Instead, it would be better to control the access through your own views. Removing such routes and keeping only a few routes, say create and destroy, would look something like:

resources :posts, :only => [:create, :destroy]

The above rule would be relevant in situations where you’d not want your users to be able to edit posts. This can be thought of as similar to what Twitter does in disallowing you to edit your tweets. The new auto-generated routes will now be:

              POST      /posts(.:format)
              DELETE    /posts/:id(.:format)

There’s another handy alternative in cases where you’d want to skip out only some specific routes from being auto-generated. For example, in the post model, if you wanted to restrict only destroy route from being auto-generated (and all others to stay the same), you can use the :except rule as shown below:

resources :posts, :except => [:destroy]

This will generate the following routes:

posts         GET       /posts(.:format)
              POST      /posts(.:format)
new_post      GET       /posts/new(.:format)
edit_post     GET       /posts/:id/edit(.:format)
post          GET       /posts/:id(.:format)
              PUT       /posts/:id(.:format)

Note the absence of the missing DELETE route which we did not want to generate. This can be used in scenarios where you don't want to allow the user to delete any data from the table — particularly in scenarios where you’re maintaining a list of monetary transactions or recording a user’s in-application activity.

Similarly, you can also use the :only rule to specify only the routes you want to be generated.

Use Collection and Model Routes

An interesting feature that Ruby routing offers is segregating collection and model routes. This comes in handy when you’re trying to define a route that is different from the seven routes defined by the RESTful routing by default. An example of such a route can be a preview or search feature. Ruby offers two different kinds of routing paradigms to handle such routes and their context – model routes and collection routes.

Model Routes

Model routes are used to define routes that are intended to be used on a single instance of the resource at once. This can include use-cases such as a resource preview, or an archive, or an activate/deactivate call on some switchable resource. An example of model routing can be:

resource :posts do
  post :archive, on: :member
end

The above syntax defines archive as a member route for posts, meaning that a single post can be archived with it.

Collection Routes

Collection routes are used to define routes that can operate over a collection of resource instances. This can include use-cases such as a search or a general retrieval of a list of items. An example of collection routing can be:

resource :posts do
  get :search, on: :collection
end

The above piece of code defines search as a member route on posts, allowing the posts table to be searched through.

What if you need both, model routes and collection routes in a resource?

Here’s how you can combine the two:

resource :posts do
  member do
    post :archive
  end

  collection do
    get :search
  end
end

This is mentioned here because, just like shallow routing reduces clutter, collection and model routes can be used wisely to make sure that additional routes (such as archive or search) are created only in the contexts where they are needed.

Add Nested Routes to Your Ruby Repertoire

While nested resources seem like a fun feature to play with, they must be handled with extreme care, as one extra keyword can lead to several unnecessary routes that can eventually cut down on your application’s performance.

Talking about application performance, don’t forget to check out Scout APM, an excellent tool for monitoring application performance and identifying that one silly block of code that has been burning a hole in your pocket! Scout offers an array of features for Ruby application monitoring and can be a great asset in developing optimized Ruby apps.

Looking back at the post — we went over how routing works in Ruby and where exactly the nested version of routing comes in. We also understood ways of implementing nested resources, and then went on to analyze several best practices to follow when creating nested resources. Ultimately, it comes down to you to ensure that you use nested resources to achieve what you intend to, with minimum overheads, with optimizations like shallow routes, and member-collection routes. And if you ever feel like you’re missing out on performance, our monitoring always has your back!