โ† Back to Course

Reading the Source ยท Card 2

How Rails turns get "/posts" into a controller call

You write one line in config/routes.rb. Rails does five things with it when your app starts up, then four more every time a browser sends a request. Once you've seen each step, routing stops feeling like magic.

1. The line you wrote is a method call.

config/routes.rb looks like a config file, but it's normal Ruby code. It runs one time, when your app starts. The draw do ... end block is a method call on a Ruby object Rails owns, and the get inside is another method call. Once your app has finished booting, the routes file is done and won't run again. None of it runs when a request arrives.

# config/routes.rb

Rails.application.routes.draw do
  get "/posts", to: "posts#index"
end

# Read out loud, this is:
#
#   "Call `draw` on the application's routes object,
#    and inside that block, call `get` with these arguments."
#
# Both are method calls. The whole file is Ruby.

๐Ÿ“‚ actionpack/lib/action_dispatch/routing/route_set.rb, the draw method

2. Each line becomes a small Route object.

When the get method runs, Rails creates a small Ruby object called a Route and adds it to a list. That list, in the order you wrote the lines, is your whole routing table.

# Roughly, here's what Rails stores for one route line:

Route.new(
  name:      "posts",
  path:      "/posts",
  verb:      "GET",
  endpoint:  PostsController.action(:index),
  defaults:  { controller: "posts", action: "index" }
)

# Every line in routes.rb makes one of these.
# They live in a list, kept in the order they were declared.

๐Ÿ“‚ actionpack/lib/action_dispatch/journey/route.rb

3. The path becomes a regex.

A path like /posts/:id doesn't stay as a string. Rails turns it into a regular expression where :id becomes a named group that captures whatever the user typed. When a request comes in, Rails matches the URL against that regex, and the captured value becomes params[:id].

# The path "/posts/:id" becomes (roughly) this regex:

%r{\A/posts/(?<id>[^/.?]+)\Z}

# When a request comes in for "/posts/42":

match = path_regex.match("/posts/42")
match[:id]  # => "42"

# That captured value is how params[:id] ends up filled in.

๐Ÿ“‚ actionpack/lib/action_dispatch/journey/parser.rb and compiler.rb

4. Lookup is fast because Rails builds a map.

On the first request, Rails takes all those Route objects and builds a structure called a state machine. Think of it like a tree of paths. To match a URL, Rails walks down the tree one piece at a time, and the matching route falls out at the end. The walk takes about as long as the URL itself, no matter how many routes you have. Adding hundreds of routes makes your app boot a little slower, but each request stays fast.

# Imagine these three routes:
#
#   get "/posts"
#   get "/posts/:id"
#   get "/users/:id/posts"
#
# Rails arranges them into a tree:
#
#                    start
#                   /     \
#                  /       \
#              "posts"    "users"
#               /  \         |
#              /    \        |
#         (done)   :id      :id
#                            |
#                          "posts"
#                             |
#                          (done)
#
# To match "/posts/42", Rails follows: start โ†’ "posts" โ†’ :id โ†’ done.
# Three small steps, and the matching route is found.

๐Ÿ“‚ actionpack/lib/action_dispatch/journey/gtg/transition_table.rb

5. Rails finds the controller and calls your action.

Once the URL matches, Rails reads the "posts#index" string and turns it into a real class (PostsController) and a symbol (:index). Then it asks the controller class for a callable. The callable is what actually creates a controller instance, runs your before_actions, calls your action method, and gives back the response.

# At the end of a match, Rails does roughly this:

PostsController.action(:index).call(env)
# => [200, { "Content-Type" => "text/html" }, ["<html>...</html>"]]

# .action(:index) returns a small Rack app. When called, it:
#   1. creates a new PostsController instance
#   2. sets up @request and @response on it
#   3. runs your before_action callbacks
#   4. calls the index method
#   5. returns the response triple

# That's the bridge from "the routes file" to "your action method."

๐Ÿ“‚ actionpack/lib/action_controller/metal.rb, the action class method

6. What your app holds in memory after boot.

Once the routes file finishes running, your app is holding four things:

  • One routes object (the RouteSet).
  • A list of Route objects inside it, one per line you wrote.
  • A state-machine map, built the first time a request arrives.
  • A set of helper methods for your named routes (posts_path, post_url, etc.), mixed into your views and controllers.

When a request comes in, Rails walks the map, finds the route, and calls your controller. The routes file itself never runs again.

Why this is worth knowing

Plenty of Rails developers ship features for years without knowing any of this. What changes once you do know it:

  • Weird routing bugs become readable. A custom constraints: block that seems to do nothing makes sense once you know constraints get checked during the map walk, and a custom constraint object needs a matches?(request) method.
  • The rails routes command makes more sense. The output is a printout of the list of Route objects your app is holding in memory. Inspecting it is inspecting state.
  • One line like resources :posts stops being mysterious. It generates seven Route objects, plus URL helpers for each, the same way belongs_to generates a family of methods behind one declaration.
  • When routing breaks in production, you know where to look. Path matched but the action 404s? Step 5. Path doesn't match at all? Steps 3 and 4. A helper like post_path isn't defined? Step 6.