Everyday Rails

Rails architecture tips from route helpers

By Aaron Sumner, June 22, 2020.

I wanted to share a little something cute I noticed a couple of weeks ago while adding a new feature to an old Rails application. It gave me a new appreciation for the attention to detail that the authors of the Rails framework put into making it a pleasant development experience.

For the sake of demonstration, let’s pretend I was adding a new endpoint for a user to add an avatar to their account, and for other users to view that avatar. Due to other imaginary implementation details, we use a third-party service for handling avatars, and not a few extra fields on the existing users table in the database backing our app. We just need to integrate with that made-up service. An avatar could be server-rendered HTML, or an API that returns JSON–again, doesn’t matter for this story.

The application is legacy, by which I mean, someone besides me wrote it. So I don’t have full context behind all the decisions behind its design and architecture. But let’s say there’s an existing UsersController, and it contains familiar Create/Read/Update/Delete (CRUD) methods like new, show, create, and update, specific to records in the database’s users table. But it also includes non-standard methods like add_permission, and links. What these methods do isn’t important, just that they’re outside the typical scope of a single Rails controller as a web front-end for a single resource’s CRUD actions.

I could follow this pattern as established in the existing code, and include my avatar-related functionality on this controller, and update my routes configuration accordingly:

resources :users do
  # existing extra routes
  get :avatar
  get :new_avatar
  post :create_avatar
end

Looking at the routes alone, that doesn’t seem too bad, I guess. But things start to get gnarly when writing the controller code. Pretty quickly, we start to see the controller’s responsibility for resources outside of the User model grow. And with that responsibility comes controller complexity. And with that complexity comes difficulty testing and maintaining.

If I looked at the route helpers generated by Rails for these new actions (using the rails routes command line tool), I might’ve sooner gotten the sense that I was heading down a wrong path:

Helper HTTP method Path Controller/action
user_avatar GET /users/:user_id/avatar(.:format) users#avatar
user_new_avatar GET /users/:user_id/new_avatar(.:format) users#new_avatar
user_save_avatar POST /users/:user_id/save_avatar(.:format) users#save_avatar

user_avatar_path is fine. user_new_avatar_path is sort of fine, maybe. But user_save_avatar_path is gross. It just doesn’t read well. It’s like I’m saying, Hey user, save avatar! and not Hey Rails program, save this user’s avatar! even though the User model has zero responsibility for managing avatar data. It’s almost like Rails is telling me not to continue down this rabbit hole. So I won’t. What about a nested resource, instead?

resources :users do
  resource :avatar, only: [:show, :new, :create]
end

For this example, I’m limiting this to just the show, new, and create actions to correspond with the functionality from the first approach. But there’s no reason this couldn’t handle editing/updating or deleting avatars, too.

With the nested resource in place in my routes, here are the corresponding helpers:

Helper HTTP method Path Controller/action
new_user_avatar GET /users/:user_id/avatar/new(.:format) avatars#new
user_avatar GET /users/:user_id/avatar(.:format) avatars#show
user_avatar POST /users/:user_id/avatar(.:format) avatars#create

It’s subtle, but new_user_avatar_path reads more like plain English than the corresponding user_new_avatar_path, doesn’t it? And a POST to user_avatar now follows Rails convention, expecting a create method on AvatarsController. I still have to write that AvatarsController class with a create method, but the rest of the plumbing is done for me.

Grammar matters, and in this case, thinking about code from a human perspective, and not a computer perspective, pointed me toward a better design with smaller, less complicated controllers. Smaller controllers are easier to maintain, and easier to test. And by following the Rails convention of limiting controllers to the seven CRUD actions, I’ve got a clean template for adding my new functionality.

I know that full appreciation of this feature of Rails assumes proficiency in English grammar. But Rails and Ruby have pretty much always leaned into this expectation as a means to reach the goal of programmer happiness. And programmer happiness is what keeps me reaching for Ruby on Rails, 15 years in.

By the way, if you’re not sure about this in your own code, you can experiment with routes and route helpers before writing any corresponding controller code. This is a great way to confirm whether you’re on the right track with your design. And if you haven’t before, I strongly recommend reading Rails Routing from the Outside In for a more thorough overview of routing in Rails, to help understand all the different ways you might structure your app’s HTTP entry points for programmer happiness.

What do you think? Follow along on on Mastodon, Facebook, or Bluesky to let me know what you think and catch my latest posts. Better yet, subscribe to my newsletter for updates from Everyday Rails, book picks, and other thoughts and ideas that didn't quite fit here.
Buy Me A Coffee

Test with confidence!

If you liked my series on practical advice for adding reliable tests to your Rails apps, check out the expanded ebook version. Lots of additional, exclusive content and a complete sample Rails application.

Newsletter

Ruby on Rails news and tips, and other ideas and surprises from Aaron at Everyday Rails. Delivered to your inbox on no particular set schedule.