def eligible_books
done = completed_books
curriculum.books_at(…)
.reject { curriculum.archived?(_1) }
.select { … }
end eligible_books made in the recorded runs, grouped by who received it. Nearly
all of them went to Curriculum, so that's where the method belongs, taking the one thing
it still asks Enrollment for as an argument.
The problem
A method belongs with the data it works on. When it spends its time asking another object questions, it’s in the wrong class. That’s feature envy, and it’s how single responsibility erodes: one class quietly does another’s job, and a change to the second now means editing the first.
class Enrollment
def eligible_books
done = completed_books
level = curriculum.level_for(student)
curriculum.books_at(level)
.reject { |book| curriculum.archived?(book) }
.select { |book| curriculum.prerequisites(book).all? { |pre| done.include?(pre) } }
end
end
Every line but one is about Curriculum. Reading it, you can see that. Across a codebase, you can’t:
nobody reads every method asking whose data it’s really using, and a linter counting receivers in the
source can’t tell curriculum from any other local, or how often each call actually runs.
The fix
Ask which methods envy another object:
$ ra feature_envy app/models/enrollment.rb
Enrollment#eligible_books
9 calls to Curriculum for every 1 to itself (2,340 recorded calls)
Curriculum level_for, books_at, archived?, prerequisites
self completed_books
move it onto Curriculum SRP
it needs from Enrollment: student (Student), completed_books (Set)
Move it, passing in the one thing it still needs from Enrollment:
class Curriculum
def eligible_books(student, completed:)
books_at(level_for(student))
.reject { |book| archived?(book) }
.select { |book| prerequisites(book).all? { |pre| completed.include?(pre) } }
end
end
class Enrollment
def eligible_books = curriculum.eligible_books(student, completed: completed_books)
end
Curriculum now answers a question about its own books, and the four calls it used to receive from
outside are calls to itself.
Where it shows up
- Your editor (LSP). The method name gets a hint with the ratio and the destination, and a quick-fix that moves it and leaves a delegating method behind.
- Your agent (MCP). The agent asks for recommendations for one method, one class or the whole codebase, and gets the destination and the parameters the move needs.
- The command line.
ra feature_envyon a file or directory, or as part ofra refactor.
How it works
- Count by receiver. The recorder wraps every method your app defines, so each call is an edge
from the calling method to a receiving class and method. For each method,
feature_envytotals the calls it made in every recorded run, grouped by the class that received them. - Compare with self. When one other class receives far more of those calls than the method’s own class does, the method is flagged, with that class named as the destination.
- Work out what moves with it. The calls it still makes on its own object are what the moved method needs passed in. Argument classes, recorded with each call, give their types.
Because these are counts from real runs, a call inside a loop weighs what it actually costs, and a branch that never runs doesn’t count at all.
Limits
- App methods only. Calls to Ruby’s own classes, like
Array#selectorSet#include?, aren’t recorded, and nor are instance variable reads. A method that works hard on its own state with plain Ruby can look more envious than it is. - Some envy is the point. A presenter or a report exists to read another object. The ratio is evidence, not a verdict, so it’s a hint and never fails a build.
- Only what ran. A branch no recorded run took isn’t in the counts.
Related tools
split_interface
Split a fat interface. A class whose callers each use a different slice of it. It names the role interfaces to split it into, measured from who actually calls what.
See more →relocate_file
Which file should move where. Two files that lean on each other constantly but sit directories apart. The graph names the folder they belong in, weighed by real call traffic and the distance between them.
See more →what_it_receives
What a method receives. The observed shape of each argument at a call site. The messages the method actually sends to what it's passed, not only the class it was given.
See more →