Software Architecture · Architecture Evaluation

Instability, Abstractness, and Distance from the Main Sequence

How afferent and efferent coupling produce two ratios per package, why their sum locates a package in a zone of pain or uselessness, and where the arithmetic stops being informative.

A package that many things depend on and that depends on many things is the most expensive kind of package to change, and the most common kind in a system nobody has evaluated. Two ratios separate the two directions of that traffic and put a package on a scale, which makes the expensive ones visible without reading any code. The metrics are crude, they are easy to compute wrongly, and they are still the cheapest first pass over an unfamiliar codebase.

Counting traffic in both directions

For a package P, afferent coupling C_a counts the classes outside P that depend on classes inside P. Efferent coupling C_e counts the classes inside P that depend on classes outside P. Both are class counts, not edge counts, and the asymmetry matters: one class imported by forty others contributes 1 to C_a per importing class but is not weighted by how many of its members they use.

Instability is the ratio of outgoing to total traffic:

I = Ce / (Ca + Ce)

The value lies in [0, 1]. A package with no dependents has I = 1: nothing breaks if it changes, so it is maximally unstable, which is a good state for a package full of policy-free detail. A package with no outgoing dependencies has I = 0: it can be depended on and cannot be changed cheaply, which is a good state for something that must not move.

Abstractness is the fraction of the package’s types that are abstract — interfaces, abstract classes, and in languages without those constructs, whatever plays the same role:

A = abstract types / total types

Neither ratio means anything alone. High instability is fine for a leaf and fatal for a shared library. High abstractness is fine if something uses it and pointless if nothing does.

The line, and the two zones off it

The main sequence is the set of packages where A + I = 1: packages are either stable and abstract, or unstable and concrete. The distance from it is

D = |A + I - 1|

normalised to [0, 1] by construction, since both terms are. Distance zero means the package sits on the line. Large distance means it sits in one of two regions that fail for opposite reasons.

The zone of pain is low A and low I. The package is concrete and many things depend on it. Any change to it ripples outward and cannot be absorbed by an implementation swap, because there is nothing to swap. This is where database schemas end up when they are modelled as concrete types in a shared package, and where a shared “utils” package ends up after a few years of accretion.

The zone of uselessness is high A and high I. The package is abstract and nobody depends on it. Interfaces with no implementations in use, or implementations that live elsewhere and are called directly, sit here. It is the residue of a design that anticipated flexibility and then did not use it. The cost is modest in runtime and real in reading: a reader following a call into an abstract package finds no caller and no implementation.

The line itself is not a target to maximise everywhere. A package of pure data transfer types has A = 0 because the language offers no abstract form for a record; it also has high I if it is a leaf, so it lands near the unstable end of the line and the metric is content. The arithmetic rewards whatever the language calls abstract, which is one of several reasons to read D as a cue for investigation rather than a grade.

A calculation over a small package set

The computation is short enough that the interesting part is deciding the counting rules, not writing the loop.

from collections import defaultdict

# imports: importer package -> set of packages it imports from
def metrics(packages, imports):
    ca = defaultdict(set)
    ce = defaultdict(set)
    for pkg, deps in imports.items():
        for dep in deps:
            if dep != pkg:
                ce[pkg].add(dep)
                ca[dep].add(pkg)      # reverse edge, counted once per importer
    rows = []
    for pkg in packages:
        abstract, total = packages[pkg]        # (abstract types, all types)
        i = len(ce[pkg]) / max(len(ca[pkg]) + len(ce[pkg]), 1)
        a = abstract / max(total, 1)
        rows.append((pkg, len(ca[pkg]), len(ce[pkg]), a, i, abs(a + i - 1)))
    return sorted(rows, key=lambda r: -r[5])

Note what the loop counts: one unit per importing package, not per imported symbol. A package that uses a single constant from a huge library contributes exactly as much to that library’s C_a as a package that uses everything in it. Using class-level counts instead of package-level ones changes the numbers substantially, and no version is canonically right; the rule has to be fixed and stated, and comparisons made only within one rule set.

A worked example with six packages shows the characteristic shape. A domain package with 30 types, 10 of them abstract, imported by 5 packages and importing nothing: I = 0, A = 0.33, D = 0.67. It is stable and mostly concrete, which is either correct — domain types genuinely are concrete — or the zone of pain, depending on whether the abstraction belongs there. A web package with 20 types, none abstract, importing 6 packages and imported by nobody: I = 1, A = 0, D = 0, the unstable and concrete end of the line. A plugins package with 8 abstract types out of 8, imported by nobody, importing 4: I = 1, A = 1, D = 1, the uselessness corner.

That last case is worth dwelling on. Its score is the worst possible, and its code may be entirely reasonable if the plugin API is consumed by out-of-tree code that the analysis cannot see. The metric does not know about consumers outside the repository. Neither does it know about consumers reached through configuration rather than imports, which is a structural blind spot described in the dependency matrix discussion.

Where the arithmetic stops describing the design

Four problems recur often enough to state plainly.

Class counts are a poor proxy for dependency strength. A package whose 200 types are each used by a different importer has the same C_a as one that exposes a single type used by all 200: the count cannot tell a shallow dependency from a deep one. The instability figure ignores both the number of types and the depth of use, so ranking packages by I across a codebase tends to surface the small, heavily-typed packages rather than the load-bearing ones.

Averaging over packages hides bimodality. A package that is half stable-and-abstract and half unstable-and-concrete can score well while containing an internal violation worse than any package-level number in the report. This is the same granularity problem that makes strongly connected components necessary: the package is the wrong unit for some questions.

Reflection, dependency injection containers, and string-keyed registries remove edges from the graph. A service locator turns every caller-to-implementation edge into an edge to the container, which flattens C_e for everyone and inflates the container’s C_a. Frameworks that generate wiring code shift the graph at build time.

Abstractness is language-dependent in a way that is not comparable across a polyglot repository. Go interfaces are satisfied implicitly and live in the consumer, so counting them under the defining package gives a different answer than the Java convention of declaring interfaces beside their implementations. Any cross-language comparison of A is meaningless; the numbers only compare a package to its own history.

Using the numbers as a trend, not a gate

The strongest use of these ratios is a time series. Plot I, A, and D per package against commits for a year, and the interesting events are the moves: a stable package gaining dependents while losing abstract types is drifting toward the zone of pain, and that drift is visible months before anyone complains about the cost of changing it. A package whose abstractness rises while its dependents and outgoing dependencies stay flat is accumulating abstraction that nothing uses.

Setting a threshold in continuous integration is the weaker use, and usually counterproductive. A rule that every package must have D < 0.3 produces gratuitous interfaces in packages that should be concrete, and the cheapest way to satisfy it is to add an interface nobody implements. That is the zone of uselessness, created by the metric. A defensible enforcement is narrower: freeze the set of packages in the zone of pain, fail the build when a new one appears, and require any addition to the frozen list to be justified in the change description. This is a fitness function in the useful sense — it encodes a decision someone made on purpose, and it is narrow enough that nobody routes around it.

There is also a design constraint hiding under the arithmetic that is worth stating without the numbers. Direction of dependency is a policy decision, not an accident: the packages that hold the rules the business cares about should be the ones nothing is allowed to force into change, and the packages that hold detail should be the ones that can be replaced. Instability measures whether that policy held. When it did not, the fix is to move the abstraction, not to adjust the ratio.

References

  1. OO Design Quality Metrics: An Analysis of Dependencies — Robert C. Martin, 1994
  2. Agile Software Development: Principles, Patterns, and Practices — Robert C. Martin, Prentice Hall, 2002
  3. Large-Scale C++ Software Design — John Lakos, Addison-Wesley, 1996