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.

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

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.

Metal Controller in Rails
The ActionController::Base is the great-grandfather of all Rails controllers, but doesn’t get much attention. In this post, we will examine this Metal class and how it works.

I hope that you'll have a much better understanding and appreciation for Rails controllers after reading this post.

💡
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 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:

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.

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:

Articles Page
Articles Page

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.

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


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.