Wednesday, 23 September 2026

The price of distributing

Part of a series on how software architecture shapes AI driven code degradation. This post explains a group of measurements on their own. Each metric gets its definition, its figure, and its numbers.

Call hops from a handling step

indirection_median  ·  ↓ lower is better  ·  harness breadth-first search over the call graph

Call hops from a handling step

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

How many calls deep you have to go, from a handling step, to reach the code that does the work. Every hop is a file a developer has to open. This is the sceptic's objection made numeric: you did not remove the complexity, you buried it behind indirection.

How it is calculated
depth(k) = length of the shortest call path from any node to k
indirection_median = mediank ∈ reached depth(k)

Where. reached is every method reachable from the handling nodes. The handling nodes themselves have depth 0. Depth is assigned by breadth-first search, so it is the shortest path, not the longest. The harness also records how many methods were reached, as indirection_reached, which is the denominator.

In this harness. placement.indirection_stats, sharing the same call index as the comprehension metrics.

How to read it. Expected to favour the concentrated arm. Everything in one method is zero hops away. This is Brooks' accidental complexity as a number, and it is the honest price of decomposition.

Careful. It understates a pipeline architecture. Depth is measured from the handling nodes, so a pipeline's twenty wired steps are all at depth 0 while a single-handler arm has exactly one depth-0 method. Worse, a pipeline's hops between steps are declared in YAML and dispatched by the container, so they are not Java calls and do not appear here at all. The honest reading of a pipeline arm is this depth plus its step count. Quoting this column alone would let the arm with the most indirection report the least.

Deepest call chain

indirection_max  ·  ↓ lower is better  ·  harness breadth-first search over the call graph

Deepest call chain

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

The longest shortest-path from a handling step to any reachable method. It is the worst-case navigation cost for one change.

How it is calculated
indirection_max = maxk ∈ reached depth(k)

Where. Same depth assignment as the median. Because every depth is a shortest path, this is the eccentricity of the reachable set rather than the length of the longest walk.

In this harness. Same single pass.

How to read it. A rising maximum with a flat median means one long tail appeared rather than a general deepening.

Share of reached methods more than two hops away

indirection_deep_share  ·  ↓ lower is better  ·  harness breadth-first search over the call graph

Share of reached methods more than two hops away

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

Of everything the request path reaches, what fraction is far enough away that you would not find it by reading the handler. It is a distribution-shape measure rather than an extreme: not how deep the deepest is, but how much of the system lives out in the far field.

How it is calculated
indirection_deep_share = | { k : depth(k) > 2 } | / | reached |

Where. The threshold of 2 is a choice, and it is the only arbitrary constant in this group. It is meant as “further than the handler and the thing it obviously calls”.

In this harness. Same single pass.

How to read it. Expected to be a counter-signal, like the rest of this group.

Coupling between objects, average class

ck_cbo_mean  ·  ↓ lower is better  ·  Chidamber & Kemerer 1994, via CK

Coupling between objects, average class

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

How many other classes a class depends on. Splitting one class into six creates coupling between the six that did not exist before, so this is expected to be a counter-signal.

How it is calculated
CBO(C) = | { D ≠ C : C references D or D references C } |
ck_cbo_mean = mean over classes of CBO(C)

Where. A reference is any use of the other class: a field type, a parameter type, a local variable, a method call, a thrown exception. CK counts distinct classes, not distinct references, so calling one class fifty times still counts 1. Computed by the CK tool, which parses source with Eclipse JDT. Only declared source classes are seen: library types are not, and neither is anything the parser fails on, so the class count in the validity group is worth checking beside any CK figure.

In this harness. Computed by the CK tool, which parses source with Eclipse JDT. Only declared source classes are seen: library types are not, and neither is anything the parser fails on, so the class count in the validity group is worth checking beside any CK figure.

How to read it. If a distributed architecture keeps this flat while adding units, that is a real result in its favour, because it is the objection you would most expect to land.

Coupling between objects, worst class

ck_cbo_max  ·  ↓ lower is better  ·  C&K 1994, via CK

Coupling between objects, worst class

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

The single most entangled class in the codebase.

How it is calculated
ck_cbo_max = max over classes of CBO(C)

Where. Same CBO definition as the mean.

In this harness. Computed by the CK tool, which parses source with Eclipse JDT. Only declared source classes are seen: library types are not, and neither is anything the parser fails on, so the class count in the validity group is worth checking beside any CK figure.

How to read it. Where the mean is diluted by many small classes, the maximum is not. A rising maximum with a flat mean means one class is becoming the hub.

Fan-out, average class

ck_fanout_mean  ·  ↓ lower is better  ·  CK

Fan-out, average class

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

How many other classes a class calls out to. It is the directional half of coupling: not how entangled a class is, but how much it depends on others.

How it is calculated
fanout(C) = | { D : C references D } |
ck_fanout_mean = mean over classes of fanout(C)

Where. Unlike CBO this counts only outgoing references. CK records the incoming direction separately as fan-in, which the harness also stores.

In this harness. Computed by the CK tool, which parses source with Eclipse JDT. Only declared source classes are seen: library types are not, and neither is anything the parser fails on, so the class count in the validity group is worth checking beside any CK figure.

How to read it. Rising fan-out with flat fan-in is the orchestrator shape: a class that coordinates rather than one that is depended upon.

Response for a class, average

ck_rfc_mean  ·  ↓ lower is better  ·  Chidamber & Kemerer 1994, via CK

Response for a class, average

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

How many distinct methods could end up executing in response to one message to this class. That is its own methods plus everything they call. It is roughly the size of the behaviour you have to consider when you call into a class, which makes it both a testability and a comprehension measure.

How it is calculated
RFC(C) = | M(C) ∪ ⋃m ∈ M(C) R(m) |
ck_rfc_mean = mean over classes of RFC(C)

Where. M(C) is the methods declared by C. R(m) is the set of methods invoked by m. CK uses the one-level version, which is the common implementation: it counts methods called directly by the class's own methods and does not recurse.

In this harness. Computed by the CK tool, which parses source with Eclipse JDT. Only declared source classes are seen: library types are not, and neither is anything the parser fails on, so the class count in the validity group is worth checking beside any CK figure.

How to read it. It rises with both concentration and indirection, which makes it a useful tiebreaker between those two stories.

Response for a class, worst

ck_rfc_max  ·  ↓ lower is better  ·  C&K 1994, via CK

Response for a class, worst

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

The class with the largest response set. The worst thing in the codebase to call into.

How it is calculated
ck_rfc_max = max over classes of RFC(C)

Where. Same one-level RFC definition as the mean.

In this harness. Computed by the CK tool, which parses source with Eclipse JDT. Only declared source classes are seen: library types are not, and neither is anything the parser fails on, so the class count in the validity group is worth checking beside any CK figure.

How to read it. Read it beside the handler's own RFC below. If the worst class in the codebase is the handler, that is the thesis. If it is something else, say so.

Response set of the handler class

ck_handler_rfc  ·  ↓ lower is better  ·  CK, pinned to the handler class

Response set of the handler class

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

How much behaviour is reachable from one call into the endpoint's class. It is closely related to the whole-path complexity in the comprehension group, but computed by a different tool, in units of methods rather than branches, and only one level deep.

How it is calculated
ck_handler_rfc = | M(H) ∪ ⋃m ∈ M(H) R(m) |

Where. H is the handler class. One level only, so unlike the comprehension walk this does not follow the call graph transitively.

In this harness. Computed by the CK tool, which parses source with Eclipse JDT. Only declared source classes are seen: library types are not, and neither is anything the parser fails on, so the class count in the validity group is worth checking beside any CK figure.

How to read it. A second opinion on the handler's comprehension load, from a tool with a different parser and a different definition.

Coupling of the handler class

ck_handler_cbo  ·  ↓ lower is better  ·  CK, pinned to the handler class

Coupling of the handler class

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

How many other classes the endpoint's own class depends on. A handler that collects dependencies is a handler that is accumulating responsibilities.

How it is calculated
ck_handler_cbo = | { D ≠ H : H references D or D references H } |

Where. Same CBO definition, scoped to the handler class.

In this harness. Computed by the CK tool, which parses source with Eclipse JDT. Only declared source classes are seen: library types are not, and neither is anything the parser fails on, so the class count in the validity group is worth checking beside any CK figure.

How to read it. In a pipeline architecture the dependencies move to the wiring, which is exactly the kind of relocation the framework-dispatch caveat is about. A very low figure here for an arm implementing sixty rules is a prompt to check the validity group.

Law of Demeter violations

pmd_demeter_violations  ·  ↓ lower is better  ·  Lieberherr & Holland 1989, via PMD

Law of Demeter violations

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

Places where code reaches through one object to get at another, as in a.getB().getC().doThing(). It is the classic symptom of a class knowing too much about its neighbours' internals, and it is another externally defined detector with thresholds nobody here chose.

How it is calculated
pmd_demeter_violations = count of method calls whose receiver is not this, a parameter, a locally created object, or a field of this

Where. The law says a method may only call methods on: itself, its own parameters, objects it created, and its own fields. PMD's rule reports one violation per offending call site, so a single long chain can contribute several.

In this harness. PMD's LawOfDemeter rule, from the same single spawn as the complexity measures.

How to read it. It tends to rise with distribution, so treat it as a counter-signal. It is also a famously noisy rule, so read the trend rather than the absolute count.

Propagation cost

propagation_cost  ·  ↓ lower is better  ·  MacCormack, Rusnak & Baldwin 2006

Propagation cost

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

If you change a random file, what fraction of the codebase could feel it? It is the density of the transitive closure of the file dependency matrix, and it is the one whole-architecture coupling number here with real pedigree in the modularity literature.

How it is calculated
propagation_cost = Σi=1..n | reach(i) | / n2

Where. n is the number of production Java files. reach(i) is the set of other files reachable from file i by following call edges transitively, so a file does not count itself. The numerator is therefore the number of ordered reachable pairs, and dividing by n2 gives the expected fraction of the system a random change can touch.

In this harness. placement.propagation_cost collapses the method call graph to a file graph, dropping self-edges, then runs a depth-first reach from every file.

How to read it. Lower means better modularised. Use the between-arm comparison at the same change request and nothing else.

Careful. Two load-bearing caveats. First, the n2 denominator rewards having more files, so an arm that splits the same code over more files scores lower for free. Second, it inherits a conservative call resolver that drops every edge it cannot prove. MacCormack reports 10 to 60 percent for real systems, so a value an order of magnitude below that means edges are missing rather than that the design is exceptional. Never quote the absolute value.

Files reachable from a typical file

propagation_fanout_median  ·  ↓ lower is better  ·  MacCormack et al. 2006, unnormalised

Files reachable from a typical file

Line is the mean of ten runs. Band is one standard deviation. Click for full size.

The raw count behind propagation cost. From one file, how many files can you reach by following dependencies. Because it is not normalised it cannot be improved by adding files, which makes it the honest version.

How it is calculated
propagation_fanout_median = mediani=1..n | reach(i) |

Where. Same file graph and same transitive reach as the propagation cost, with no n2 division. The harness also records the maximum and the file count.

In this harness. Same single pass as the propagation cost.

How to read it. If the normalised and unnormalised numbers tell different stories, the difference is packaging rather than structure. That comparison is the reason both are reported.

No comments:

Post a Comment