def total = Billing::Proration
.for(self).adjust(subtotal) The problem
Code that changes together should live together. That’s the common closure principle, and directory
layouts drift away from it one reasonable decision at a time. Invoice went in app/models because
it’s a model. Proration went in app/billing because it’s billing. They’ve talked to each other on
every request since.
# app/models/invoice.rb
class Invoice
def total = Billing::Proration.for(self).adjust(subtotal)
end
# app/billing/proration.rb
module Billing
class Proration
def self.for(invoice) = new(invoice.plan, invoice.period, invoice.lines)
end
end
Change how proration reads an invoice and you’re editing two directories, reviewing two areas of the
tree, and hoping whoever owns app/models notices. Nothing in the layout tells you these two are one
thing, and nothing in the source tells you how much they depend on each other compared to everything
else.
The fix
Ask which files are in the wrong place:
$ ra relocate_file app/billing/proration.rb
app/billing/proration.rb ⇄ app/models/invoice.rb
4,102 calls between them, in both directions
Proration has no other caller; Invoice has 2 others
3 directories apart: models ⇄ billing
gather both into app/domains/billing CCP
Move them, and let the constants follow:
app/models/invoice.rb → app/domains/billing/invoice.rb Invoice → Billing::Invoice
app/billing/proration.rb → app/domains/billing/proration.rb
With Zeitwerk the directory is the namespace, so Invoice becomes Billing::Invoice. Its two other
callers are in the report, and what_a_change_touches lists every call site to update in the same
commit.
How it works
- File to file traffic. Every recorded call knows the file its calling method was defined in and the file of the method it reached. Summed over every run, that’s a weighted graph of files.
- Exclusivity. For each pair it checks how much of each file’s traffic the other one accounts for. Two files that mostly talk to each other score high; a utility everyone calls doesn’t.
- Distance. It measures how far apart the two sit in the directory tree. Heavy, exclusive traffic across a long distance is the signal.
- Destination. It names the folder that puts the pair closest together while moving the least: where most of their combined traffic already lives, or a shared parent.
Limits
- Layers on purpose. If you group by type (
models,services,jobs) deliberately, it will keep suggesting you group by domain. That’s the principle it measures, so read it as a choice you’re making rather than a mistake. - Traffic, not history. It measures which files call each other, not which ones change in the same commits. Git history is a different signal, and worth checking alongside.
- It suggests, it doesn’t move. Moving a file renames constants and breaks references it can’t see, like strings in config. The move is yours, or your agent’s, to make.
Related tools
break_cycle
Cut a dependency cycle. Files or namespaces caught in a runtime dependency cycle, flagged with the single lowest-traffic edge to sever to break the loop.
See more →split_package
Split a package used in parts. Consumers that pull in a whole namespace but touch disjoint pieces of it. Split it so nobody takes a dependency on code they never call.
See more →what_a_change_touches
What a change touches. Every caller that relies on a method's observed shape, the argument classes it passes and the messages it sends to what comes back, so you know what a signature change reaches before you make it.
See more →