Against stability
Toward stability
The problem
Some code is load-bearing. Finance::Accounts is called from fourteen namespaces, and it depends on
almost nothing. Everything leans on it, so it has to change slowly and carefully. Other code is
meant to churn: Reporting is called by two things and depends on nine, and it changes every time
someone wants a new column.
The stable dependencies principle says dependencies should point from the second kind to the first. Here, one points the wrong way:
module Finance
class Accounts
def debit(account, amount)
entry = ledger.append(account, -amount)
audit.log(Reporting::Formatters::Currency.new(amount).to_ledger_s)
entry
end
end
end
Now a change to a report formatter can break every debit in the app. Nobody decided that. Someone needed a currency string and the formatter was right there. You can’t see it from the file either: the line looks harmless, and how stable each side is only shows up when you count who calls whom.
The fix
Ask which dependencies run against stability:
$ ra stabilize_dep finance
finance → reporting against stability
finance instability 0.12 called from 14 namespaces, calls into 2
reporting instability 0.81 called from 2 namespaces, calls into 9
1 call site, 8,930 calls:
Finance::Accounts#debit → Reporting::Formatters::Currency#to_ledger_s
move it into finance, or depend on an abstraction finance owns SDP · DIP
One call site is the whole dependency, so the cheap fix is to move the method down to where it’s needed and let reporting depend on finance instead:
module Finance
class Money
def to_ledger_s = format("%.2f %s", amount, currency)
end
class Accounts
def debit(account, amount)
entry = ledger.append(account, -amount)
audit.log(amount.to_ledger_s)
entry
end
end
end
Reporting::Formatters::Currency now calls Finance::Money#to_ledger_s if it wants the same string.
The arrow points toward the stable side. When the 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
- Callers in, callees out. 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. Calls out divided by calls in plus calls out gives a number from 0 (everyone depends on it, it depends on nothing) to 1 (nothing depends on it, it depends on everything).
- Flag the wrong direction. Any edge from a clearly more stable namespace to a clearly less stable
one is reported, with its traffic and the call sites that make it. Because the edges come from real
calls, a dependency made through
sendor a callback counts the same as a direct one.
Limits
- Stability from what ran. A namespace your recorded runs barely touched has too few callers to measure fairly. The report says how many calls each number rests on.
- No abstractness score. The classic metric also weighs how abstract a package is. Ruby has no interfaces to count, so this stops at instability and leaves the abstraction to you.
- Sometimes it’s fine. A stable core calling out to a plugin point is on purpose. It’s a hint, not a build failure.
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 →coupling_map
Coupling from real load. Module and namespace coupling drawn from real production calls. Which parts of your app actually talk, and how much.
See more →