I spent most of 2020 working with Ruby on Rails. I moved a project from Next.js + Rust to… Rails, baby! Back to the future. My earlier post on Second-guessing the modern web was inspired by this experience, that for the product we were building, a ‘modern’ stack was not working as well as a traditional one.
We didn’t do competitive analysis against Laravel, Django, or Phoenix. They’re similar, not radically better or worse. There are multiple acceptable solutions to a problem, and this was more a matter of choosing the right kind of solution than pursuing some kind of perfect choice and burning hours and motivation doing the window-shopping.
What helped Rails win was that the team had a little more experience in Ruby (with the exception of myself), and we found plenty of resources for developing and deploying the stack. Rails fit perfectly into the ideology of Choosing boring technology. Another part of the product would be the hard, innovative part, so it made no sense to grapple with bleeding-edge web frameworks.
This was a really fun experience. There’s a lot to love about Rails. Other communities could learn a bit from the Ruby & Rails culture and wisdom. I won’t implement everything in Rails, but it’ll be part of the toolbox.
A while ago, I wrote on Twitter
In Rails, there is byebug. You write
byebug in your source code, and you get an interactive REPL right there. It works in views, controllers, database migrations, everywhere. It almost always works. Variables are named what you expect. The whole system is paused at that moment, and you can actually interact with it, using all of the Rails utilities and your installed gems.
If a page crashes unexpectedly, you get a similar REPL experience, in your browser, automatically. With an automatically cleaned-up stacktrace that excludes Rails’s own frames. Like the byebug interface, this REPL actually works and is consistently helpful in finding root causes. Rarely will you need to use
puts to print something to the console because this debugging system is so good.
Our Rails app didn’t have any
require statements. You mention a module’s name, and it’s automatically included, using Zeitwerk, a tool that comes standard with Rails.
This kind of system was terrifying to me before. What if you accidentally import something just by mentioning it? What if two things have the same name and you import the wrong one? How do you really know what’s happening? Sure, you’re happy now, with all of that annoying importing and exporting taken care of, but the sky might fall.
Or maybe it just… doesn’t. Maybe impure, vaguely risky techniques are just a net positive over time, and making everything fully explicit isn’t really necessary? Now when I’m using other systems, I wonder - what if I could just mention one of my React components and it would just… be there? Sure, the system would have to complain if there were two components with the same name, and it would have to make assumptions about directory structure, but overall, wouldn’t this be nice?
This applies to a lot of other parts of the system too. Rails is famous for doing pluralization - you name a model
Post and you automatically get an interface called
posts. But what, you ask, of words with uneven pluralization rules? Rails actually does the right thing, almost always. And when it fails, you can override it. It actually just saves time, reliably.
I’ve tried to test front-end applications. I’ve set up nightwatch, jest, enzyme, cypress, and probably 5-10 other frameworks. Front-end testing is universally terrible. Projects like Cypress are throwing untold hours into making it less terrible, taking on massive amounts of complexity to abstract away from fickle browser behavior and complex interactions.
But it still sucks. Frontend testing has no good attributes: it’s unreliable, hard to automate, hard to debug when it fails, and often doesn’t even assert for important behaviors, so it doesn’t actually identify regressions. Running frontend tests in CI is resource-heavy, requiring you to set up headless X windows environments on servers or use specialized CI services that produce screencasts of test runs.
Testing fully-server-rendered applications, on the other hand, is amazing. A vanilla testing setup with Rails & RSpec can give you fast, stable, concise, and actually-useful test coverage. You can actually assert for behavior and navigate through an application like a user would. These tests are solving a simpler problem - making requests and parsing responses, without the need for a full browser or headless browser, without multiple kinds of state to track.
Not only do the tests work better, the testing culture is a completely different universe. There are entire books written about how to write RSpec tests that catch bugs, allow software evolution, and aren’t filled with boilerplate.
Powerful and dangerous.
I’m used to modules as they work in other systems - Python, Node, Elm, and so on. They provide objects, functions, and variables that you can import and combine into your code explicitly. Usually they sit on some specific level of abstraction - it’s a utility for connecting to servers or a React component you can use.
Gems can do so much more. You install something like Devise into your system and it adds views, routes, methods, utilities, you name it. It’s not like “loading some functions”, it’s more like composing a whole different app into your app, implicitly.
This is obviously terrifying. It means that you can’t look at your directories of views and your file of
routes.rb and know what exists at a glance. There are other layers, lurking in the ephemeral space of third-party code. They interact in serious but uncertain ways.
But it’s also pretty incredible - the idea that something like passport, Node’s middleware, could instead be a full-fledged authentication system. It means that you have to write a lot less code, and it also means that the people who use that code have a lot more code in common. That gems can work on a higher level of abstraction, making it possible to cobble together software faster, to write less ‘glue code.’
Even if you don’t write Ruby, you should pay attention to Sandi Metz. She’s incredibly wise and has so many incredible ideas to share.
And then there’s arkency, ThoughtBot, and so many other thoughtful writers with years of experience in Rails. Sometimes it’s a little shocking to google for some obscure problem and see a decade of discussion about it.
The best practices are also formalized into tools like Code Climate and reek. I’ve never seen so many actually-useful suggestions come out of automated systems as I did in the world of Ruby and Rails.
String.prototype.padStart instead of having every little thing in userspace. The only part that felt actively weird was activesupport - a gem that extends Ruby’s core objects, but is part of Rails. It felt weird to have string methods that would only work if your environment was Rails.
I get it - it’s hard to keep up with the latest trends in frontend. But this is one area where Rails’s strong backwards compatibility feels iffy. I wish that Rails was more opinionated about the frontend, and that it had better opinions.
In Smalltalk, everything happens somewhere else. - Adele Goldberg
Ruby, as today’s Smalltalk, has the same issue. The community venerates small - that methods should be short, files should be small, complexity should be controlled. This begs the question of where it all goes - certainly not in controllers, which should be skinny, and not in views, which should have very little logic at all, and maybe not in models either. Maybe in Service Objects, or policies, or decorators?
I found myself falling victim to this. I’d try to win CodeClimate’s approval by moving code around, perfecting the art of making everything small or at most medium-sized, extracting concerns until most files looked okay. This was time well-spent on learning, but I have to admit that it doesn’t actually matter for an early-stage startup’s product.
In stark contrast to the folks who say that Rails is for prototypes, there’s a lot of attention paid to long-lived engineering efforts - adopting patterns that let many team work on the same ‘monolith’, identifying shotgun surgery - a term I first heard from Sandi Metz.
One of the hardest bugs we encountered happened with ActiveRecord. We were creating a set of changes to apply to a model, using their in-memory instances to do some stuff, and then finally applying them. This broke because one of the ActiveRecord methods automatically ‘committed’ those changes, quietly.
ActiveRecord is kind of like this - a lot of the times it’s pleasantly implicit, letting you just assign a value and automatically saving that to the database. But then it’ll do something implicitly that you don’t want to happen, and figuring out why this happened and how to stop it from happening is a real challenge.
Most of the time, to be clear - it’s a really great system. It provides lots of ways to generate efficient-enough queries, knowing full well that SQL performance is often the bottleneck of web applications. Most of the time it’s really nice that it automatically casts and deserializes query results. But when it goes bad, the diagnosis and the cure can be pretty ugly.
The other issue with ActiveRecord is that it has efficient methods and inefficient methods right next to each other, because it automatically turns your ‘query builder’ into an array when you call array-like methods. So, for example:
Is wildly inefficient. It might fetch and deserialized a million records just to sort them and give you the first. On the other hand,
Is fast - it sorts in the database and fetches a single record. Rails is both offering smart and easy ways to write optimized code, but also making it really easy to write inefficient code.
A Rails-like framework is a really good thing to have in your toolbox, and there’s a lot to learn from the Ruby community. My hope is that we see these sorts of abstractions in new languages and frameworks, and see more of the Ruby community’s culture filter into the programming world.