Part 10 of 10 5 min read

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:

ruby
# 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
end

Twelve 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.

ruby
# 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
end

Longer. 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 readableWhat the agent finds workable
define_method over a constantEvery method written out with def
method_missing delegationBoring, explicit methods
Short names like priceUnique names like renewal_price_for that grep finds once
Behavior split into concerns and service objectsBehavior co-located in the file that uses it
"Good code doesn't need comments"Comments that explain why
Nicely formatted test outputStructured 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.

The whole series

  1. 1Code is written for humans to readStop Writing and Reading Code
  2. 2Never rewrite from scratchRewrites Might Be Better Than Massive Refactors
  3. 3Use the language your team already knowsLanguage Strengths Over Language Familiarity
  4. 4Build it once for the web and wrap itGo Native Everywhere
  5. 5Keep the monolith majesticI Hated Microservices. Agents Love Them.
  6. 6Pay someone else to run your serversOwn Your Infrastructure
  7. 7Don't repeat yourselfDuplicate Code So Your Agents Stop Colliding
  8. 8Types are ceremonyTypeScript, I Don't Hate You Anymore
  9. 9Respect the testing pyramidMore E2E Tests, Fewer Unit Tests
  10. 10Refactor for readabilityStop Refactoring for Humans. Refactor for the Agent.