Against stability
Toward stability
The problem
Some code has many dependents. In the example app Finance is called from five namespaces and calls
into three, so a change to it has to be made slowly and carefully. Other code is expected to change
often: Reporting is called from three namespaces, and it changes every time someone wants a new
column.
The stable dependencies principle says dependencies should point from code that changes often to
code that changes rarely. One dependency in the example app points the other way, in
app/finance/finance/accounts.rb:14:
def debit(account, amount, memo:)
raise InsufficientFunds, "#{account.balance} < #{amount}" if account.balance < amount
account.balance = account.balance - amount
account.save!
entry = @ledger.record(account, Money.zero - amount, memo: memo)
@audit.log(Reporting::Formatters::Currency.new(amount).to_ledger_s)
entry
end
With that line, a change to a report formatter can break every debit in the app. The dependency came from convenience: someone needed a currency string and the formatter was already written. Reading the file does not show the problem, because how stable each side is comes from counting who calls whom.
The fix
Ask which dependencies run against stability:
$ ra stabilize_dep finance
Finance
· instability 0.38 called from 5 namespaces, calls into 3 (a namespace is a class's leading module, or for a top-level class the file's first path segment after app/<layer>/)
account 0.29 309 fine
money 0.00 489 fine
Reporting 0.40 178 against stability
⚠ Finance → Reporting against stability
Finance instability 0.38 called from 5 namespaces, calls into 3
Reporting instability 0.40 called from 3 namespaces, calls into 2
1 call site, 178 calls:
Finance::Accounts#debit → Reporting::Formatters::Currency#initialize
Finance::Accounts#debit → Reporting::Formatters::Currency#to_ledger_s
→ move it into Finance, or depend on an abstraction Finance owns SDP · DIP
The whole dependency is one call site, so the cheap fix is to get the string from Money, which
already has Money#to_ledger_s in app/models/money.rb:43:
def debit(account, amount, memo:)
raise InsufficientFunds, "#{account.balance} < #{amount}" if account.balance < amount
account.balance = account.balance - amount
account.save!
entry = @ledger.record(account, Money.zero - amount, memo: memo)
@audit.log(amount.to_ledger_s)
entry
end
Reporting::Formatters::Currency can call Money#to_ledger_s if it wants the same string, and then
the dependency points toward the stable side. When a dependency is too big to move, the other option
is dependency inversion: Finance defines the role it needs, and Reporting supplies an object
that plays it.
How it works
- From every recorded call edge, rolled up to namespaces, it counts the distinct namespaces that call into each one and the distinct namespaces it calls out to.
- Instability is calls out divided by calls in plus calls out, which gives a number from 0
(everyone depends on it and it depends on nothing) to 1 (nothing depends on it and it depends on
everything).
moneyscores 0.00 because everything calls it and it calls nothing. - Any edge from a more stable namespace to a less stable one is reported, with its traffic and the
call sites that make it. The edges come from recorded calls, so a dependency made through
sendor a callback counts the same as a direct one.
In an app this small the gap is narrow, 0.38 against 0.40. The report prints both numbers and the caller counts behind them, so you can judge whether the gap means anything.
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 stabilize_dep finance
The call is at app/finance/finance/accounts.rb:20.
Reading the result
- Stability is measured from what ran. A namespace your recorded runs barely touched has too few callers to measure fairly, and the report says how many calls each number is based on.
- There is no abstractness score. The original metric also weighs how abstract a package is, and Ruby has no interfaces to count, so the report stops at instability and leaves the abstraction to you.
- Some flagged dependencies are deliberate, such as a stable core calling out to a plugin point. The report is a hint and does not fail a build.
Every tool reads the same recording. What gets recorded and how far to trust an answer are on the questions page.
Related tools
break_cycle
Cut a dependency cycle. The files or namespaces that form a dependency cycle at runtime. Each cycle is marked with its lowest-traffic edge, which is the cheapest one to cut.
See more →split_package
The parts of a namespace each consumer uses. Groups the classes in a namespace by which outside classes called them, and reports the groups that share no consumer as the lines to split the namespace along.
See more →coupling_map
Coupling measured from app runs. Which modules and namespaces of your app call each other, and how many times, counted from recorded app runs.
See more →