Refactor for readability
Stop Refactoring for Humans. Refactor for the Agent.
Ruby was built to value the programmer over the machine, and now that the programmer is an agent, optimizing for the programmer means something new.
Years ago I wrote an essay about why we taught Ruby first. I said Ruby was the only language in history invented for the programmer's happiness. That it "values you, the programmer, over the machine." I quoted _why calling Ruby "the language of our thoughts," not the computer's.
I still believe all of that. Which is why this post was the hardest one to write.
If Ruby optimized for the programmer, and the programmer is now an agent, what does optimizing for the programmer mean?
It doesn't mean "keep writing code that delights me." I'm not the one in the file anymore. It means taking Matz's value seriously and pointing it at whoever is actually doing the programming.
Before and after
I had a Post model in this blog that I was a little proud of. A list of statuses and some metaprogramming to generate everything from it:
# Before: optimized for my delight
class Post < ApplicationRecord
STATUSES = %w[draft scheduled published archived].freeze
STATUSES.each do |status|
scope status, -> { where(status: status) }
define_method("#{status}?") { self.status == status }
define_method("#{status}!") { update!(status: status) }
end
endTwelve methods in six lines. Very Ruby. I used to put this kind of thing on a slide to show why the language is fun.
The agent kept fumbling around it. It would search for def published?, find nothing, then wander through concerns and gems trying to figure out where the method came from. Once it wrote its own published?, which the generated one silently overwrote at load time.
So I asked Claude Code, more or less word for word:
Refactor the Post model so it's easiest for YOU to work with next time. Not for me to read. For you.
# After: optimized for the agent
class Post < ApplicationRecord
# Status lifecycle is documented in docs/post-status.md.
# Methods are written out explicitly so they can be found with grep.
scope :draft, -> { where(status: "draft") }
scope :scheduled, -> { where(status: "scheduled") }
scope :published, -> { where(status: "published") }
scope :archived, -> { where(status: "archived") }
def draft? = status == "draft"
def scheduled? = status == "scheduled"
def published? = status == "published"
def archived? = status == "archived"
def publish!
update!(status: "published", published_at: published_at || Time.current)
end
def archive!
update!(status: "archived")
end
endLonger. Repetitive. I would have flagged it in code review.
And the before version really is better at some things. Adding a status is a one-word change. There's less to scroll past. It's more fun, and I'm not going to pretend that doesn't matter.
But look at what the after version buys. Every method has a literal definition that rg "def published?" finds in one shot. Once the bang methods were written out, the agent could prove nothing called draft! or scheduled!, so it deleted them. It renamed published! to publish! and pulled the published_at logic in from a callback two files away, because it kept missing it there.
It also did two things I didn't ask for. It deleted a PostPresenter that forwarded everything to the model through method_missing and inlined the three methods the presenter actually added. Then it wrote docs/post-status.md, a short file describing each status and what moves a post between them. I've never opened it. It's for the next session, so the agent doesn't rebuild the lifecycle from scratch.
Readable vs workable
A few months of asking that same question across the codebase, and the answers are consistent. They're close to the opposite of what I used to teach.
| What I found readable | What the agent finds workable |
|---|---|
define_method over a constant | Every method written out with def |
method_missing delegation | Boring, explicit methods |
Short names like price | Unique names like renewal_price_for that grep finds once |
| Behavior split into concerns and service objects | Behavior co-located in the file that uses it |
| "Good code doesn't need comments" | Comments that explain why |
| Nicely formatted test output | Structured output it can parse and diff |
The common thread: metaprogramming and indirection are invisible to search. A method built from string interpolation at load time doesn't exist until the code runs. method_missing is a trapdoor. Every hop into another file is another place context gets lost, which is most of why I now prefer duplication over abstraction.
The comments row surprised me most. A person can walk over and ask me why the retry count is 3. The agent can't. One line saves it from "fixing" something that was deliberate.
The environment is part of the code
This goes past the Ruby. The whole repo is an interface for the agent now, and I design it that way.
This project has a CLAUDE.md at the root with the rules that matter. There's a docs/conventions/ directory split by domain (architecture, data layer, frontend, hotwire, testing) so the agent loads only what's relevant. There are skills that load those conventions before any code gets written, and one that reads the system docs for whatever subsystem it's about to touch. None of it is written for a new hire.
Same with tooling. bin/agent-rspec wraps RSpec so the agent never builds the command itself. It prepares the test database, runs a full-suite baseline before work starts and writes it to JSON, runs again afterward, then diffs the two and labels every failure as new, fixed, or pre-existing. A person would find that output tedious. The agent stopped blaming its own changes for tests that were already broken on main, which used to eat whole sessions.
Anywhere the agent is guessing, parsing output formatted for humans, or poking around to rebuild context, give it something explicit instead.
Where I'd still push back
If people are in part of the code every day, it still has to be pleasant for them. And some metaprogramming is Rails itself. has_many and belongs_to are fine, because the agent has seen them a million times. The problem is homemade magic, the stuff that exists only in your app.
But the default has flipped. When I feel the urge to make something more elegant, I ask who it's for. Usually it's for me, and I'm not going to read it.
Dijkstra said the tools we use shape how we think. Ruby shaped me to ask who the language serves, and that question is the whole of Rethink Everything. Ruby taught me a language could care about the person writing it. I'm just extending that courtesy to whoever that is now.