def report_row = Reporting::Row
.new(student.name, progress) The problem
Dependencies between parts of an app should run one way. That’s the acyclic dependencies principle: once two namespaces depend on each other, they’re one namespace in disguise. You can’t load, test, extract or reason about either without the other.
Cycles rarely arrive on purpose. Reporting has always read from Enrollment. Then someone needed a
report row for an enrollment, and the obvious place to put it was on Enrollment:
module Reporting
class Report
def rows = enrollments.map(&:report_row)
def progress_for(enrollment) = enrollment.progress
end
end
class Enrollment
def report_row = Reporting::Row.new(student.name, progress)
end
Now reporting → enrollment → reporting. Ruby won’t complain: constants resolve when the line runs,
so nothing fails to load. Static tools struggle too, because a cycle can be closed by a send, a
callback or a constant looked up by name, and none of those show up as a reference.
The fix
Ask for the cycles, and where to cut them:
$ ra break_cycle
cycle · reporting → enrollment → reporting
Reporting::Report#progress_for → Enrollment#progress 3,418 calls
Reporting::Report#rows → Enrollment#report_row 12 calls
Enrollment#report_row → Reporting::Row.new 12 calls ← cut
report_row has 1 caller: Reporting::Report#rows
cut Enrollment#report_row, the only edge closing the cycle ADP
The busy edge is the one that should exist: reports read enrollments. The cycle is closed by a single method with a single caller. Move it to the side that already depends on the other:
module Reporting
class Report
def rows = enrollments.map { |enrollment| Row.for(enrollment) }
end
class Row
def self.for(enrollment) = new(enrollment.student.name, enrollment.progress)
end
end
Enrollment no longer mentions Reporting at all, and the dependency runs one way again.
How it works
- Roll calls up. Every recorded call is an edge from one method to another, and each method knows its file and namespace. Rolled up to the level you ask for, files or namespaces, that’s a directed graph with real call counts on every edge.
- Find the loops. Any group of namespaces that can each reach the others is a cycle. The direction of each edge is certain, because every record says which side made the call.
- Rank the cuts. Within a cycle, each edge is weighed by its traffic and by how many distinct call sites it takes to remove. The cheapest one to sever is flagged, with the methods that make it.
Limits
- Runtime dependencies only. A constant that’s referenced but never called through, like a class
named in a
rescueclause, isn’t a recorded call, so it can’t close a cycle here. - Cheapest isn’t always right. The lowest-traffic edge is the least work to cut. Sometimes the right fix is to reverse a busy edge instead, and that’s a design call the counts can’t make for you.
- Only what ran. A cycle through code no recorded run reached won’t be found.
Related tools
stabilize_dep
Point dependencies at stability. A stable module, with many callers and few dependencies, reaching into a less stable one. Measured from real call traffic, so you can depend toward stability or put an abstraction between them.
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 →check_diff
Catch it in review. Point it at a branch or PR and it flags only what the change introduced, before it lands: a drifted mock, a fresh dependency cycle, new feature envy.
See more →