I’ve worked with more than one Ruby on Rails application that has a catch-all GET route at the end of its config/routes.rb file:

Rails.application.routes.draw do
  resources :posts
  resources :users

  # This route is defined after all other routes as a catch-all.
  get "/*path", to: "fallbacks#show"
end

What this means is that there is a FallbacksController that handles any routing that isn’t already handled by previously-defined routes. In this simple example, a path like /posts/123, /posts/doesnt-exist, or /users/brock would be rightly handled by the resource routes that have been defined, but some other path like /about or /contact would be routed through FallbacksController#show. If the fallbacks controller becomes responsible for figuring out what content the user wants to view or rendering some other page like a 404 page:

class FallbacksController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound, with: :render_404

  def show
    # If a page with the given path cannot be found, `#find_by!`
    # raises an `ActiveRecord::RecordNotFound` exception.
    @page = Page.find_by!(path: params[:path])
  end
end

This simple controller action looks the same way many other simple controller actions look, where the main difference is that we’re handling requests for any path not already handled by other routes. /about would route to our static page with a #path attribute of /about. /page-that-doesnt-exist would route to a 404.

When a catch-all route accumulates complexity

But the applications I’ve worked on that have implemented a catch-all route like this don’t intend to just serve content for /about or /contact pages. They all have implemented the catch-all so that they can present connected groups of pages that have a tree-like structure, like a taxon tree:

/unisex
└── /unisex/jeans
    └── /unisex/jeans/raw
└── /unisex/tops
    └── /unisex/tops/oversized

And using this taxon tree, they often want to present increasingly filtered-down groups of products for a product listing page.

The Rails applications I’ve worked on that provide this catch-all route have all stuffed a lot of complexity into their FallbacksController#show action.

For example, if we decide that some subset of pages should be rendered in some other way:

class FallbacksController < ApplicationController
  JEANS_SECTION = "/unisex/jeans"
  TOPS_SECTION = /unisex/tops"

  rescue_from ActiveRecord::RecordNotFound, with: :render_404

  def show
    path = params[:path]

    # If a page with the given path cannot be found, `#find_by!`
    # raises an `ActiveRecord::RecordNotFound` exception.
    @page = Page.find_by!(path: params[:path])

    if path.starts_with? JEANS_SECTION
      render "jeans_listing"
    elsif path.starts_with? TOPS_SECTION
      render "tops_listing"
    else
      render "show"
    end
  end
end

Now, even though we are rendering three different templates, our only dependency is a @page record. In this example controller action, the code indicates that our pages are basically all the same but can now be presented in three different ways. In my opinion that’s an acceptable amount of complexity.

But this can get out of hand fast if we want other variations of the product listing pages, or tack on other pages that require a top-level route like /about or /contact:

class FallbacksController < ApplicationController
  GRAVEYARD_CATEGORY = "graveyard"

  JEANS_SECTION = "/unisex/jeans"
  TOPS_SECTION = /unisex/tops"

  STATIC_PAGES = ["/about", "/contact"]

  rescue_from ActiveRecord::RecordNotFound, with: :render_404

  def show
    path = params[:path]

    # If a page with the given path cannot be found, `#find_by!`
    # raises an `ActiveRecord::RecordNotFound` exception.
    @page = Page.find_by!(path: params[:path])

    # Only include products that have been discontinued.
    @products =
      if path.includes?(GRAVEYARD_CATEGORY)
        Product.where(discontinued_at: ..Time.now, taxons: @page.taxons)
      else
        Product.where(taxons: @page.taxons)
      end

    render_404 and return if (@products.none? && !STATIC_PAGES.include?(path))

    if STATIC_PAGES.include?(path)
      render "static_page"
    elsif path.starts_with? JEANS_SECTION
      render "jeans_listing"
    elsif path.starts_with? TOPS_SECTION
      render "tops_listing"
    else
      render "show"
    end
  end
end

So here we’ve added a lot of functionality to FallbacksController#show.

  • the rendering of static pages
  • the rendering of a “product graveyard” for collections of discontinued products
  • the rendering of a 404 page if a collection of @products happens to be empty and we’re not trying to render a static page like /about.

Obviously this is a bit contrived; if we actually needed to render all of these pages from a single controller action, we might restructure it a bit. But hopefully this highlights the fact that… this should have always been served by many controllers.

Using typical Rails routes, undoing this technical debt could be a considerable amount of work, though, if we don’t want to break the existing routing. And, of course, maybe we do have requirements for static pages like the about page: that they are served via /about rather than /some-controller-prefix/about.

Reducing complexity using routing constraints

The least-effort way around the controller action stuffing would be use routing contraints, which allows us to stop stuffing all of this complexity into a single controller action. You can express this either as a lamdba or as a class that adheres to Rails’s contraints API:

class PageConstraints
  # Matches page paths. To be really nice, we'll optionally account for a
  # slash at the beginning and/or end of the request path. i.e. `about`,
  # `/about`, `about/`, or `/about/`.
  PAGE_PATH_REGEXP = %r{\A/?[^/]*/?\z}

  def matches?(request) = request.path.match?(PAGE_PATH_REGEXP)
end

class GraveyardCategoryConstraints
  GRAVEYARD_CATEGORY_KEYWORD = "graveyard"

  def matches?(request)
    request.path.match? %r{.+#{GRAVEYARD_CATEGORY_KEYWORD}.*}
  end
end

Rails.application.routes.draw do
  resources :posts
  resources :users

  # Example using a custom constraints lambda.
  get "/*path",
    contraints: ->(request) {
      request.path.match?(Product.known_category_prefixes)
    },
    to: "products#show"

  # Example using a custom constraints class.
  constraints GraveyardCategoryConstraints.new do
    get "/*path", to: "graveyard_products#show"
  end

  # Example using a custom constraints class.
  contraints PageConstraints.new do
    get "/*path", to: "pages#show"
  end

  # Aaaaaannd the old fallback route.
  get "/*path", to: "fallbacks#show"
end

I won’t include example code for all of the individual controllers, but I hope you can see how splitting the single, monolithic FallbacksController#show action into four separate controllers would make each of the actions much more maintainable.