A Deep Dive into Routing and Controller Dispatch in Rails
Rails has a lot of magic that we often take for granted. A lot is going on the behind the clever, elegant abstractions that Rails provides us as users of the framework. And at a certain point, I find it’s useful to peek behind the curtain and see how things really work.
But opening the Rails source code can be absolutely daunting at first. It can feel like a jungle of abstractions and metaprogramming. A large part of this is due to the nature of object-oriented programming: by its nature, it’s not easy to follow a step-by-step path that would be taken at runtime. Sometimes, it helps to have a guide.
With this in mind, let’s take some time to explore how routing works in Rails. How does a web request accepted by Rack make it all the way to your Rails controller?
For the purposes of this example, consider a Rails app with a single route and controller:
In this app, a
GET request to
/users will be routed to the
UsersController. But how?
A lot of collaboration happens to get from request to controller, so it will be useful to get a bird’s eye view before diving in. Here’s a diagram that shows how routes defined in
routes.rb are registered within Rails at boot time. We’ll explore these classes in more detail shortly:
And here’s the sequence of events that occurs when we make our
GET request to
The file you know and love! From a Rails framework perspective, this is the public interface. Declare your routes in this file, and Rails will take care of figuring out how to route a request to the right controller.
I kind of lied when I said
routes.rb was the public interface. It’s really a DSL to the public interface. The
RouteSet is the actual class that acts as the entry point for route configuration in a Rails application. It’s most famous for the
#draw method, which we’ve just used in
RouteSet is responsible for coordinating the entire operation that we’re about to dive into: it receives the incoming web request, and collaborates with the objects below to determine how the request makes it into our application code.
Once upon a time,
Journey was a standalone gem, before it was merged into ActionPack. It focuses on routes, and figuring out how to route an incoming request. It doesn’t know about Rails at all, nor does it care — give it a set of routes, then pass it a request, and it will route that request to the first route that matches.
How it performs the routing in an efficient way is fascinating, and there’s a great talk from Vaidehi Joshi that goes into detail on the internals of
Journey. I highly recommend it!
Journey::Routes holds on to the routes that our Rails app knows about.
RouteSet delegates to it whenever a new route is registered at startup, whether that’s from
routes.rb, an engine, or a gem like Devise that defines its own routes.
If we think of
Journey::Routes like an array, then
Journey::Route objects are the elements inside. In addition to the metadata you’d expect this object to hold on to, like the path of the route, it also holds a reference to
app, which will get invoked if that route is chosen to serve the request.
In this way, each
Journey::Route is kind of like a tiny web app that responds to a single endpoint. It has no knowledge of other routes aside from its own, but it can guide our request in the right direction when the time comes.
Contrary to what you might think, the
app that lives inside of each
Journey::Route object is not some reference to the controller. There’s one more level of indirection here, as a means of keeping Rails code separate from the routing logic that
Journey concerns itself with.
Dispatcher is a small class which is responsible for instantiating the controller and passing along our request, along with an empty response object. It’s invoked when a suitable route is identified for a request. It has no knowledge about how a request arrived on its doorstep, but it knows what to do when it sees our request: instantiate the
UsersController and hand it our request. As we’ll see, it acts as an object factory for our controllers, removing the need for us to declare our controller classes anywhere outside of the classes themselves.
This might seem like an almost needless indirection, but it’s worthwhile considering that
Dispatcher's position between routing logic and controller classes allows either to change without affecting the other.
Journey::Routes knows nothing about requests. It knows about routes, and it will quickly and efficiently identify the correct one for the request. So in order to map an incoming request to a route, we need something that knows about a request and a route. Enter
Router that actually invokes the
Dispatcher once a route has been found.
Hey, we know what this is already! Welcome home. 😌 Now let’s connect the dots.
Back to where it all began…
Let’s circle back to our routes file:
RouteSet is the source of truth for all available endpoints in our application, it’s also first in line to receive a request from the outside world, after passing through Rack and various middleware. That’s right, this humble class buried in
ActionPack is the Walmart greeter of our application, ready with a smile and a wave as soon as a request comes through the door.
In order for
RouteSet to accept the request after it’s travelled through Rack and any middleware, it needs to implement Rack’s interface, which is as simple as implementing
Here we build a new request object. This will end up being a fresh instance of
ActionDispatch::Request, populated from
env, which is the incoming hash that Rack serves us.
After doing some string gymnastics on the incoming path, we pass the request off to
@router, which is an instance of
Journey::Router. We pass it a request and ask it to serve that request.
Journey::Router#serve, we iterate through the routes that match the path in the request:
Pay special attention to this line:
req.path_parameters = set_params.merge parameters
# `req.path_parameters` is now a hash that
# might look familiar:
Notice that we’re actually enriching the request object itself with metadata that’s returned from the
find_routes method. This is quite subtle, but it’s how
Journey communicates with the rest of the system. Once it identifies a matching route for the request, it “stamps” that knowledge onto the request itself, so that subsequent objects that deal with the request (like
Dispatcher) know how to proceed. Foreshadowing!
Anyway, when a match is finally found, we ask the route’s app to serve the request, then return the familiar array from any Rack app of status, headers, and body.
The reason for all this indirection is separation of concerns. In theory,
Journey can function perfectly fine outside of a Rails application, and as a result it’s abstracted the concept of an “app” into anything that implements Rack’s interface.
It’s here that Rails comes back into the picture. As I mentioned before, each object behind
route.app is actually an instance of
Dispatcher is our entry point back into Rails land. It knows that a request is served by a controller, and it knows that the way to talk to a Rails controller is to send it a
#dispatch method and pass along the action, the request object, and a fresh new
ActionDispatch::Response object to write the response into.
Notice that in the
#controller method above, we punt the question of which class to use to the request itself. When our request was first born, it had no idea who should be handling its request; it was just a glorified hash with a ton of metadata coming from the outside world. But thankfully, it passed through
Journey's hands, who imbued it with a few crucial pieces of data:
Armed with this knowledge, the request object itself is now in a position to answer the question, “which controller should serve my request?”
Here’s what that looks like in the
Buried deep in the Rails framework is a great example of the Factory Pattern at work. We want to automagically choose the right class to handle our incoming request, and we don’t want to hardcode a list of all of our controllers anywhere, because that would be a pain. Since we now have a string, “users”, that tells us which controller this request wants to go to, we can build up the official class name,
UsersController, and use
#constantize to turn that into the class constant. Along with help from
Dispatcher, which ends up invoking the method above, we have a way of instantiating the right controller for the request at runtime.
This is also a great example of the Open/Closed principle. Since Rails makes the assumption that your controllers are going to be named a certain way, you’re free to define a new controller simply by creating a new class that follows the naming convention, and defining its matching route. At no point do you have to update some ungainly mapping of route -> controller, or even register your controller anywhere. It’s the adherence to this principle that powers the Rails mantra of convention over configuration.
Now we’re getting really close: a message has been sent to the
UsersController! Through a series of intermediary methods, we finally invoke the method
#index on the controller:
It looks like a lot, but ultimately we’re just using Ruby’s
send method to invoke the correct action on our controller instance. Simplified, it might look something like this:
Unwinding the Abstraction
We just looked at a lot of objects. It can be hard to follow the path of execution when we need to bounce around so many different files. As a reminder, here’s the sequence of events again:
Another way to help clarify our understanding could be to reduce all of these steps down to a single method. Stripping away some of the abstraction, it might end up looking something like this:
If you made it this far, congratulations! 🎉 As you can see, there’s a lot going on behind the scenes, but hopefully this has helped to demystify some of the magic and appreciate the object-oriented principles at work.
Next time you add a new controller to your Rails app, sit back and appreciate just how much heavy lifting Rails is doing to take care of the details.
If you want to explore this code further, run
bundle open actionpack from your Rails app’s directory and have a look at the classes we’ve explored, or check out the
actionpack code on GitHub. Have fun!