Part 7 of 10 5 min read

Don't repeat yourself

Duplicate Code So Your Agents Stop Colliding

Shared app-level abstractions become collision points when a dozen agents work in parallel, so I'm choosing a little duplication over a lot of merge pain.

"What would it look like if this lived in one place?"

That was my favorite code review question for years. You'd copy a method into a second class, I'd ask it, you'd extract a module, and everyone felt smarter. I taught DRY like it was a moral value.

Mixins were a big part of why I loved Ruby. In an essay I wrote years ago about why we taught Ruby first, I quoted Matz: "I want to make Ruby users free. I want to give them the freedom to choose." Ruby gave you a dozen ways to share behavior: modules, concerns, included hooks, define_method. Pulling three models' worth of logic into one tidy include Notifiable felt like the language rewarding you for good taste.

That joy belonged to the person writing the code. It goes away fast when ten agents are editing the same file at once.

Same feature, two shapes

Here's how I work now. Five to ten agent workstreams, each in its own worktree, each with its own plan. Agent A is adding order shipping notifications. Agent B is adding comment digests. Neither knows the other exists.

The app, the old way:

ruby
# app/models/concerns/notifiable.rb
module Notifiable
  extend ActiveSupport::Concern

  included do
    after_create_commit :notify_recipients
  end

  def notify_recipients
    notification_recipients.each do |user|
      NotificationMailer.with(record: self, user: user).created.deliver_later
    end
  end
end

Order, Comment, and Invitation all include it. Eight years ago I would have put this on a slide.

Agent A needs orders to notify when status changes, not just on create. So it does the reasonable thing and edits the concern:

ruby
included do
  after_commit :notify_recipients, on: [:create, :update]
end

Agent B wants to skip the immediate email when a user has digests turned on. It edits notify_recipients in the same file. Both agents run their specs. Both go green. Both report success.

Then I merge, and notify_recipients conflicts. That part is fine. An agent resolves it in a minute.

The part that isn't fine is the change that merged cleanly. Agent A's callback now fires on every update for every model that includes Notifiable. Someone fixes a typo in a comment, and every participant gets another email. An invitation's last_viewed_at gets touched, and the invitee gets re-invited. Agent A never opened comment.rb or invitation.rb. Why would it? Its task was orders. Its tests were order tests.

The same work the new way, with no concern at all:

ruby
class Order < ApplicationRecord
  after_commit :notify_customer, on: [:create, :update], if: :saved_change_to_status?

  private

  def notify_customer
    OrderMailer.with(order: self).status_changed.deliver_later
  end
end

class Comment < ApplicationRecord
  after_create_commit :notify_participants

  private

  def notify_participants
    post.participants.excluding(user).each do |participant|
      next DigestQueue.add(self, participant) if participant.digest_enabled?
      CommentMailer.with(comment: self, user: participant).created.deliver_later
    end
  end
end

Invitation has its own little version too, and neither agent touches it.

Shared NotifiablePer-model callbacks
Agent A editsnotifiable.rborder.rb
Agent B editsnotifiable.rbcomment.rb
MergeConflict in notify_recipientsClean
InvitationSilently re-sends on every updateUntouched
What each agent needs to understandAll three modelsOne model

Each agent can hold its entire blast radius in its context window, because the blast radius is one file.

What DRY bought, and what duplication costs

DRY wasn't a superstition, so let me be fair to it.

What it bought you was one place to fix a bug. If the mailer call was wrong, you fixed Notifiable and all three models were fixed. With humans that mattered a lot, because humans forget the third copy. The third copy is where bugs went to live for years.

What duplication costs is drift. Three copies slowly become three slightly different things. Fix a bug in Order and Comment still has it.

But finding every copy used to depend on someone's memory. Now it's a grep:

bash
rg "deliver_later" app/models

An agent can find all three, fix all three, and run all three model specs in one pass, as a single dedicated task. Drift is a cleanup job. A shared hot file is a traffic jam that every feature pays for, every day.

Sandi Metz said "duplication is far cheaper than the wrong abstraction." I quoted her plenty, mostly to argue for waiting until the third case before extracting. I think there's a bigger reason now. Even the right abstraction is expensive when it's a hot edit point. A module that five features share is a module five parallel agents will edit. That's not a design problem. It's a concurrency problem, and the lock is your merge.

What I'm not saying

I'm not saying rip out ActiveRecord.

Framework abstractions are fine. ActiveRecord, ActionMailer, has_many, Turbo: nobody, human or agent, is editing those. They're stable, documented, and the model knows them cold. An abstraction you only consume and never change doesn't cause collisions.

The problem is app-level abstractions that are still alive. Your Notifiable. Your Trackable. Your BaseService with the clever call wrapper. The dependency injection container someone set up so you could swap a payment gateway you've never swapped. When a class takes six shared collaborators through its constructor, an agent changing one of them has to reason about every consumer. Mostly it won't. It'll run the specs it was told to run and move on.

There are still places I want one source of truth. Authorization rules. Money math. Anything where two slightly different copies means a security hole or a wrong invoice. Those I extract, and I treat them like framework code: small, stable, rarely edited, and when they do change, that's its own workstream, not a side effect of a feature.

My rule is roughly this. Extract when something has stopped changing. Duplicate while it's still moving. If three models later settle into the exact same stable code, an agent can consolidate it in an afternoon, the same way rewrites got cheap.

Matz wanted Ruby users to be free. The freedom I care about now is each model getting to be different without asking permission from the other two.

I used to ask what it would look like if the code lived in one place. Now I ask how many agents are going to be standing in this file at the same time.

If the answer is more than one, give them each their own copy.

This is part of Rethink Everything. Next up: types, and why I don't hate TypeScript anymore.

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.