This is the fourth article in the series on building a web application in Ruby without using Rails. In the previous article, we built a working router, and this one shows how to implement a simple controller structure just like Rails in only four lines of Ruby.
If you could take only one lesson from this post, it's this:
The incoming HTTP request doesn't hit your Rails controller's action method automagically out of thin air (something I used to think a while ago when I wrote ASP.NET MVC controllers), but there's a bunch of framework code behind the scenes that receives the request from the app server like Puma, processes it, creates an instance of the controller (just like any other class), and calls the action method on it. Then it takes the response returned by the action, processes it, and sends it back to the app server, which returns it to the browser.
If the last statement made you curious enough to dig deeper and trace an incoming HTTP request's path to the Rails controller, check out the following post after your read the current article.

I hope that you'll have a much better understanding and appreciation for Rails controllers after reading this post.
What We've Built So Far
To recap, this is where we were at the end of the previous post.
- The
config/routes.rb
file creates the routes. The blocks return the HTML response corresponding to each path.
# config/routes.rb
require_relative '../router'
Router.draw do
get('/') { "Akshay's Blog" }
get('/articles') { 'All Articles' }
end
- The router stores the path-to-handler route mapping and uses the handler to generate the response.
# router.rb
class Router
# store the handler for the path
def get(path, &blk)
@routes[path] = blk
end
# find the corresponding handler for a URL
# and invoke it to build the response
def build_response(env)
path = env['REQUEST_PATH']
handler = @routes[path] || ->(env) { "no route found for #{path}" }
handler.call(env)
end
end
- The application generates the response using the router and sends it to the application server.
# app.rb
require_relative 'config/routes'
class App
def call(env)
headers = { 'Content-Type' => 'text/html' }
response_html = router.build_response(env)
[200, headers, [response_html]]
end
end
It works as expected; however, there's a small issue with the above structure.
In the routes file, we're defining all the request-handling logic as blocks. For simple routes or for debugging purposes, it's totally fine. However, for regular routes that involve a bit more logic, you may want to organize them using the "controller" classes.
The 'Controller' pattern lets you group all the request-handling logic for a route into a single class.
For example, a ArticlesController
class might handle all incoming requests related to articles, such as creating, displaying, updating, and deleting articles, a UsersController
class will handle all user-specific requests, and so on.
Not only will it keep our code clean and tidy, but it will also limit the complexity as we add new functionality to our application, resulting in maintainable code.
Let's examine one way to implement the 'Controller' pattern.
What We'll Build
We are going to implement a controller structure similar to Rails. By default, Rails stores the controllers are stored in the controllers
directory. We'll do the same.
Here's an example controller class (not exactly similar to a Rails controller, but good enough to keep it simple. We'll make it more Rails-like later):
# controllers/articles_controller.rb
class ArticlesController < ApplicationController
# GET /articles/index
def index
'all articles'
end
end
After creating a controller class and the action method, you can define a route to the controller action as follows:
get 'articles/index'
Whenever the user navigates to the articles/index
URL, our application will call the index
method on the ArticlesController
class, also passing the HTTP request environment, i.e. the env
hash will be accessible in the index
action. Just like Rails.
Later, we'll use the Rails conventions, so we don't have to use index
in the URL, inferring it by default.
Let's get started.
Step 1: Update the Router
Let's modify the router so it recognizes and parses a controller#action
route.
The following code shows only the changes required to the router.rb
file. Specifically, we require the articles_controller.rb
file and update the get
method to handle the new syntax.
# router.rb
require_relative 'controllers/articles_controller'
def get(path, &blk)
if blk
@routes[path] = blk
else
if path.include? '/' # 'articles/index'
controller, action = path.split('/') # 'articles', 'index'
controller_klass_name = controller.capitalize + 'Controller' # 'ArticlesController'
controller_klass = Object.const_get(controller_klass_name) # ArticlesController
@routes[path.prepend('/')] = ->(env) {
controller_klass.new(env).send(action.to_sym) # ArticlesController.new(env).index
}
end
end
end
The comments should be self-explanatory, but let's take a closer look at each step.
First, we check if a block is provided. This is to support the existing approach of returning the response directly from the block from the routes file.
If a block is not provided, we enter the else branch, which checks if the route follows the controller/action
pattern. Right now, I'm simply checking this via the presence of /
, but we'll switch to a regular expression like /
\/
?([a-z]+)
\/
([a-z]+)/
in future.
If the path is in the controller/action
format, we split it using the /
to retrieve the controller and action names. Then we capitalize the controller name articles
and append Controller
to it. So articles
becomes ArticlesController
, which is a String. Then we get the corresponding constant for ArticlesController
string using the const_get
method. This is the ArticlesController
class.
Now that we have the controller class and know the action to execute (index
), we add a handler (lambda) for the articles/index
path which, when invoked, creates a new instance of ArticlesController
and calls the index
action on it. We also pass the env
hash representing the HTTP request environment to the controller's constructor.
It goes without saying, that this is one way to accomplish this. If you know a better approach that's even simpler, please let me know in the comments.
If you're wondering how handler lambda can access the variables outside its scope, remember that it's a 'closure' , which gives it access to the controller and the action. To learn more, check out the following post:

Step 2: Create a Controller Class
Create a new controllers
directory and add a Controller class called ArticlesController
as follows:
- The constructor accepts the
env
hash so all action methods can access it. - It contains a single action called
index
which returns the response.
# controllers/articles_controller.rb
class ArticlesController
attr_reader :env
def initialize(env)
@env = env
end
def index
'All Articles'
end
end
For now, the index action is returning a simple string. In the next post, we'll see how we can return an ERB view, just like Rails.
Step 3: Add the Route
Finally, update the routes.rb
file so our application parses the route and uses the controller action to generate the final view.
# config/routes.rb
require_relative '../router'
Router.draw do
get('/') { "Akshay's Blog" }
get 'articles/index'
end
That's it. We're done. Start the server and navigate to /articles/index
path, you should see this:

Now we could stop here. However, there's a small refactoring we could do to make the controller look more like Rails.
Let's extract the constructor to the base class.
Refactoring: Move Constructor to Base Controller
Since all controllers will need a constructor that accepts the env
hash, it's better to pull it up in a base class. Let's stick to Rails conventions and call it ApplicationController
.
# controllers/application_controller.rb
class ApplicationController
attr_reader :env
def initialize(env)
@env = env
end
end
Now our ArticlesController
class can extend from this class and we can remove the redundant code.
# controllers/articles_controller.rb
require_relative 'application_controller'
class ArticlesController < ApplicationController
def index
'All Articles'
end
end
Restart the application and make sure everything is still working as expected.
Nice, clean, and tidy. We have a functioning controller structure which puts us well on the path to implementing views and generating dynamic HTML using the ERB gem, which we'll explore in the next post in the series.
That's a wrap. Here's our roadmap for upcoming posts:
- Implement models and views, just like Rails!
- Improve the project structure and organization
- Add unit tests
- Handle errors and logging
- Process form inputs along with query strings into a
params
object - Connect to the database to store and fetch data
- Add middleware to handle specific tasks like authentication
- and much more...
If those sound interesting to you, consider subscribing to the blog.
Trust me, it's going to be a lot of fun, so stay tuned!!
I hope you liked this article and you learned something new.
As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you.
If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.