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?
- How to Use Nested Routes?
- Best Practices for Using Nested Routes
- Add Nested Routes to your Ruby Repertoire
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:
- The Ruby language.
- The correct version of Development Kit, if you are using Windows.
- 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 :post, null: false, 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:
- Update the form partial to list the nested resource with the correct relationship to its parent.
- Navigate to the comments/index.html.erb and add a reference to the parent post of the comment by adding the following piece of code – <%= link_to 'Show Post, [@post] %>
- Update the edit, delete, and new comment links to point to the correct nested URL of the resource.
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!