Model-View-Controller Pattern

Implementing Rails-like Controllers in a No-Rails App

In its essence, a controller class in Rails is a coordinator. It accepts the incoming HTTP request and builds the data required by the view using the domain models. This post shows a highly simplified implementation of the controller pattern for a better understanding of Rails controllers.

9 min read

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.

💡
Our Goal: To build a highly simplified implementation of the controller pattern to better understand Rails controllers.

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.

How a Ruby Method Becomes a Rails Action: Part One (Metal)
In this post, we will explore how a simple Ruby method, when added to a controller, becomes an action in Rails, ready to process incoming HTTP requests and send responses. We’ll also trace the path of an incoming HTTP request to a Rails controller action.

In its essence, a controller class in Rails is a coordinator. It accepts the incoming HTTP request and builds the data required by the view using the domain models. I hope that you'll have a much better understanding and appreciation for Rails controllers after reading this lesson.

💡
If you just want to see the final source code, I've uploaded it on GitHub.

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 body corresponding to each path.
# config/routes.rb

require_relative '../router'

Router.draw do
  get('/') { "Akshay's Blog" }
  
  get('/articles') { 'All Articles' }
  
  get('/articles/1') do |env| 
    puts "Path: #{env['REQUEST_PATH']}"
    "First Article"
  end
end
  • The router stores the path-to-handler route mapping and uses the handler to generate the response.
# router.rb

require 'singleton'

class Router
  include Singleton  # 1

  attr_reader :routes

  class << self
    def draw(&blk)  # 2
      Router.instance.instance_exec(&blk)  # 3
    end
  end

  def initialize
    @routes = {}
  end

  def get(path, &blk)
    @routes[path] = blk
  end

  def build_response(env)
    path = env['REQUEST_PATH']
    handler = @routes[path] || ->(env) { "no route found for #{path}" }
    handler.call(env)  # pass the env hash to route handler
  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

  private
    def router
      Router.instance
    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. Specifically, it accepts an incoming request, decides what to do with it, gathers necessary models to generate the response, and sends the response to the client.

You group all the functionality needed for a domain concept (a resource) in a single controller class.

For example, an 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 in the controllers directory. We'll do the same.

Here's an example controller class we'll build.

# controllers/articles_controller.rb

class ArticlesController < ApplicationController

  # GET /articles/index
  def index
    'all articles'
  end
end

Note: Don't write any code yet, the above example is only to show what we'll build in this lesson.

💡
As you may have probably guessed, our little controller class is not exactly similar to a Rails controller, which stores the data in the instance variables. However, it's good enough to keep it simple and explain the fundamentals of controllers. We'll make it more Rails-like later, once we introduce the concept of a model.

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. But ignore that for now.

Let's get started. We'll build our controller in three simple steps.

Step 1: Create a Controller Class

Create a new controllers directory and add a Controller class called ArticlesController in a file named articles_controller.rb as follows:

# controllers/articles_controller.rb

class ArticlesController
  attr_reader :env
  
  def initialize(env)
    @env = env
  end
  
  def index
    '<h1>All Articles</h1>'
  end
end

Note two important things in this class:

  1. The constructor accepts the env hash and stores it in an instance variable, so that all action methods can access it.
  2. It contains a single action called index which returns the response.

For now, the index action is returning a simple HTML string. In the next post, we'll see how we can return an ERB view, just like Rails.

Step 2: Update the Router

Let's modify the router so it recognizes and parses a controller#action route.

Specifically, we require the articles_controller.rb file and update the get method to handle the new routing syntax, i.e. controller/action while still supporting the older syntax, where we passed the block.

The following code shows only the changes required to the router.rb file. Specifically,

  1. The get method is updated to parse the controller and action name.
  2. Two private methods find_controller_action and constantize are added.
# router.rb

require_relative 'controllers/articles_controller'

# ...

def get(path, &blk)
  if blk
    @routes[path] = blk
  else
    @routes[path] = ->(env) {
      controller_name, action_name = find_controller_action(path)   # 'articles', 'index'
      controller_klass = constantize(controller_name)               # ArticlesController
      controller = controller_klass.new(env)                        # ArticlesController.new(env)
      
      controller.send(action_name.to_sym)                           # controller.index
    }
  end
end

private

  # input: '/articles/index'
  # output: ['articles', 'index']
  def find_controller_action(path)
    result = path.match /\/(\w+)\/(\w+)\/?/  # path = '/articles/index'
    controller = result[1]
    action = result[2]
    return controller, action  # ['articles', 'index']
  end
  
  # input: 'articles'
  # output: ArticlesController
  def constantize(name)
    controller_klass_name = name.capitalize + 'Controller'  # "ArticlesController" (a string)
    Object.const_get(controller_klass_name)  # ArticlesController  (a class)
  end

I've added comments to make the code self-explanatory, but let's take a closer look at each step.

In the get method, first we check if a block is provided. This is to support the existing approach of returning the response directly, when a handler block is provided.

if blk
  @routes[path] = blk
else
  # ...
end

If a handler block was not provided, that means a route path was provided, for example get '/articles/index'. We have to parse this route to extract the controller and action names.

Why? So that we can instantiate the controller and call the action method on it, which is what Rails does.

First, we create a new lambda block and assign it to @routes[path]. This lambda will be executed whenever the incoming HTTP request path matches the stored route. We also pass the env hash representing the HTTP request environment to the controller's constructor.

Inside the lambda, we want to find the corresponding controller and call the appropriate action method on it. For example, given the path /articles/index, the handler should create a new instance of ArticlesController and call the index action on it.

First, we extract the controller and action names using the find_controller_action method. I'll use the regular expression: \/(\w+)\/(\w+)\/? which grabs the controller name and the action name.

# input: '/articles/index'
# output: ['articles', 'index']
def find_controller_action(path)
  result = path.match /\/(\w+)\/(\w+)\/?/  # path = '/articles/index'
  controller = result[1]
  action = result[2]
  return controller, action  # ['articles', 'index']
end

Once we have the controller name as a string, i.e. articles, we want to get the corresponding controller class, i.e. ArticlesController. The constantize method handles that by capitalizing the controller name articles and appending 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 constantize method in the ArticlesController class.

# input: 'articles'
# output: ArticlesController
def constantize(name)
  controller_klass_name = name.capitalize + 'Controller'  # "ArticlesController" (a string)
  Object.const_get(controller_klass_name)  # ArticlesController  (a class)
end

Finally, at the end of the handler block, we invoke the action method by calling the send method on the controller instance, passing the symbolized name of the action method.

Once invoked, the action method returns the string response, which is returned from the handler block to the router, which sends it to the app.

controller = controller_klass.new(env)    # ArticlesController.new(env)
controller.send(action_name.to_sym)       # controller.index

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:

Blocks, Procs, and Lambdas: A Beginner’s Guide to Closures and Anonymous Functions in Ruby
Closures and anonymous functions are very useful concepts, especially in functional programming. Ruby makes working with them a pleasure via its flavors like blocks, procs, and lambdas. This post provides an in-depth tutorial and shows the subtle differences between them.

In the next lesson, we'll add the final piece: a route that tells our application to send an incoming request to a specific action method on a specific controller class.

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:

All Articles
All Articles

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: Extract 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 name the base class ApplicationController.

Create a new file application_controller.rb under controllers directory with the following code in it:

# 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.

💡
Check out the final code in the GitHub repository in 'controllers' branch.

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!!


That's a wrap. I hope you found this article helpful 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 reply to all emails I get from developers, and I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. Your email is respected, never shared, rented, sold or spammed. If you're already a subscriber, thank you.