def report_row = Reporting::Row
.new(student.name, progress) The problem
The acyclic dependencies principle says that dependencies between parts of an app should run one way. Once two namespaces depend on each other they behave as one namespace, and you can’t load, test or extract either without the other.
In the example app Reporting reads from Enrollment. A later change needed a report row for an
enrollment, and the method that builds it was put on Enrollment.
app/reporting/reporting/report.rb:7:
def rows
enrollments.map(&:report_row)
end
app/models/enrollment.rb:51:
def report_row
Reporting::Row.new(student.name, progress)
end
The dependency now runs Reporting → enrollment → Reporting. Ruby raises no error, because
constants resolve when the line runs and both files load. A static tool can miss the cycle, because
the edge that closes it can be a send, a callback or a constant looked up by name, and none of
those appear in the source as a reference.
The fix
ra break_cycle lists the cycles and the edge to cut in each. The Reporting cycle shown above is
the second one in this report:
$ ra break_cycle
break_cycle
· 1 cycle at the namespaces level (a namespace is a class's leading module, or for a top-level class the file's first path segment after app/<layer>/)
tangle · 12 members and 24 edges in one component: Billing, Finance, Reporting, book, curriculum, email_notifier, enrollment, invoice, invoice_line, invoice_mailer, progress, student
the shortest cycles inside it, cheapest to cut first:
… the Billing ⇄ invoice ⇄ invoice_line cycle
⚠ cycle · Reporting → enrollment → Reporting
Enrollment#report_row → Reporting::Row#initialize 3 calls
Reporting::Report#rows → Enrollment#report_row 2 calls ← cut
rows is only reached from an entry point
→ cut Reporting::Report#rows → Enrollment#report_row, the cheapest edge closing the cycle ADP
… 4 more cycles in the tangle
The tangle line says the twelve namespaces can all reach each other, so they form one component. The
report then lists the shortest cycles inside it, cheapest to cut first. The Reporting cycle is two
edges long, and removing either edge breaks it. The tool marks the edge with fewer calls,
Report#rows calling report_row. Whichever edge you remove, the fix is to build the row on the
side that already depends on the other, and the example app has that method in
app/reporting/reporting/row.rb:3:
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
Once you delete Enrollment#report_row, Enrollment no longer refers to Reporting.
How it works
- Every recorded call is an edge from one method to another, and the recording holds each method’s file and namespace. The tool groups the calls at the level you ask for, files or namespaces, which gives a directed graph with a recorded call count on every edge.
- Any group of namespaces that can each reach the others is a strongly connected component. Every record says which side made the call, so the direction of each edge is known. Within a component the report lists the shortest cycles first.
- Within a cycle, each edge is weighed by its traffic and by how many distinct callers it takes to remove. The cheapest edge to cut is marked, along with the methods that make the call.
A namespace is a class’s leading module. A top-level class has none, so the tool uses the file’s
first path segment after app/<layer>/, which makes each top-level model its own namespace. That
is why the example app’s component has twelve members: book, curriculum, enrollment and
student are models in one app/models directory that call each other often, and each counts as a
namespace. --level files gives the same report by file.
Run it on your app
The output on this page comes from our example Rails app, and it is regenerated whenever a tool
changes. These are the commands behind it. On your own app, run ra record first, then put your
own class, method or file in their place.
ra break_cycle
ra break_cycle --level files
The cycle is app/reporting/reporting/report.rb:7 and app/models/enrollment.rb:51.
Reading the result
- A cycle is built from recorded calls. A constant that is referenced but has no method called on
it, such as a class named in a
rescueclause, produces no recorded call, so it does not close a cycle in this report. - The marked edge has the least traffic, which makes it the least work to cut. The better design is sometimes to reverse a busier edge, and the call counts do not tell you when that is the case.
- Each top-level class is its own namespace, so unnamespaced models in one directory are reported as cycles between files. If those cycles are noise, nest the models in modules or read the file-level report.
Every tool reads the same recording. What gets recorded and how far to trust an answer are on the questions page.
Related tools
stabilize_dep
Dependencies that run against stability. A stable namespace, one with many callers and few dependencies, calling into a less stable one. Instability is computed from recorded calls, and the report lists the call sites to move or to put behind an abstraction.
See more →relocate_file
Which file should move where. Two files that call each other often and are directories apart. The report names the folder they belong in, weighted by recorded call traffic and the distance between them.
See more →check_diff
Check the methods a diff changed. Given a base ref, it reports the findings the changed methods introduced: a stub that no longer matches the real method, a dependency cycle, feature envy.
See more →