Route-First Rails Development

If there's one thing I can say about Rails' routing framework, it's that it has fantastic documentation. URL endpoints are easier than ever to set up, making them feel like a minor detail on top of much more interesting controller, model, and view code.

The fact of the matter, however, is that your application's routes should not be taken lightly. Not only are URLs tricky to design—and yes, I do mean they must be designed like any other part of the user interface—they are even trickier to change after the fact. Unless, that is, you're comfortable with the idea of stale links becoming 404's on your site. (Hint: you shouldn't be.)

The Art of URL Design

A lot has already been said on this topic, but I think Kyle Neath puts it best:

URL Design is a complex subject. I can’t say there are any “right” solutions — it’s much like the rest of design. There’s good URL design, there’s bad URL design, and there’s everything in between — it’s subjective.

Because the process of designing URLs is so open-ended, I like to set out with a few goals in mind:

  1. Readability: Does the URL describe exactly what it points to? Without visiting the page, will I know what to expect?
  2. Conciseness: Are there any extra or redundant parameters that could be removed? Is the nesting of resources too deep?
  3. Maintainability: How will the URL hold up in the future? Does it resist change or embrace it?

Keeping these in mind, I suggest trying a route-first approach the next time you're building a feature. In other words, before writing a single line of business logic, try outlining the entire feature within routes.rb, reworking everything until you're confident that the URLs will be user-friendly, concise, and resilient to the product's ever-changing needs.

Planning a New Feature

Let's walk through a real(-ish) world example of this route-first approach.

Say you're building a new user blogging feature. In addition to the standard post and comment functionality, you need to support the ability to "subscribe" to a given blog and export the subscription data to a CSV file (which, believe it or not, is critical to the product's success in the market). An initial draft of your routes might look as follows:

resources :blogs, only: [:index, :show], shallow: true do
  resources :posts, only: [:show] do
    resources :comments, only: [:show, :new, :create]
  end
  member do
    post 'subscribe'
    get 'export_subscription', constraints: { format: 'csv' }
  end
end

At this point you might be tempted to jump straight into writing real code. But, hopefully instead, you begin to think about what else this "subscription" feature will entail, and as you do you start to see a pattern emerge.

Predicting Future Use Cases

Say we're six months (and several more features) down the road, and your routes now look like this:

resources :blogs, only: [:index, :show], shallow: true do
  # ...
  member do
    post 'subscribe'
    post 'unsubscribe'
    get 'change_subscription'
    post 'update_subscription_settings'
    get 'subscription_info', constraints: { format: 'json' }
    get 'export_subscription', constraints: { format: 'csv' }
  end
end

In case the pattern isn't entirely obvious, let me quote the fantastic documentation I mentioned earlier:

If you find yourself adding many extra actions to a resourceful route, it's time to stop and ask yourself whether you're disguising the presence of another resource.

Adding custom actions is an easy trap to fall into, because it's often the shortest path to getting a feature up and running. It isn't until a future iteration when, faced with an ever-expanding list of controller actions, you finally take the time to compact them into their own resource:

resources :blogs, only: [:index, :show], shallow: true do
  # ...
  resource :subscription, except: [:new]
end

How neat. Not even a mention of JSON- and CSV-specific actions, as they can be routed directly to subscriptions#index and handled in the controller. Had the code looked more like this from the beginning, we might have been spared a significant amount of view- and controller-level refactoring. We would also have avoided the need for two lines of code to handle the URL redirects:

get '/posts/:id/subscription_info',   to: redirect('/posts/%{id}/subscription.json'), constraints: { format: 'json' }
get '/posts/:id/export_subscription', to: redirect('/posts/%{id}/subscription.csv'),  constraints: { format: 'csv'  }

The Limits of 'Resourceful'

It's important to remember that not every feature falls neatly into a resource, so when designing a new set of routes it's often okay to add custom actions. But these cases tend to be pretty obvious, as either you're adding a vanity URL or you've specifically chosen not to follow a standard CRUD (create-read-update-delete) pattern:

resources :blogs, only: [:index, :show], shallow: true do
  # ...
  get '/:author_id/:category_id', to: 'post_categories#index'
end

Examples like this are yet another reason why starting route-first can be beneficial, as it brings up these design questions early on and can prompt interesting discussions about the directions in which a feature might go. (For instance, is this get path really necessary? Can we construct the same URL using routing namespaces or standard resources?)

Okay, So What's Next?

Having truly designed your routes, and knowing they'll be more likely to last longer, you can start filling in the gaps with working code. You can even take a TDD approach by writing request specs against your new routes, before finally implementing the business logic.

I hope that this exercise has been helpful! Feel free to shoot me an email if you have any questions, critiques, or alternate implementations.