A module diagram drawn in 2023, photographed from a whiteboard, redrawn properly, and referenced in every architecture conversation since. Nobody had compared it against the code, and after two years ten of its twenty-eight edges were wrong.
The symptom
the diagram, as maintained:
9 modules, 18 edges, last edited 2023-11
referenced in: 4 decision records, the onboarding
document, and two architecture discussions this year
the code, as it is:
9 modules, 24 edges
and the overlap: 18 edges match, 4 are drawn and do
not exist, 6 exist and are not drawn.Ten wrong edges out of twenty-eight is what two years of a hand-maintained diagram looks like, and the more interesting half is the six that exist and are not drawn. Those are the coupling that has accumulated without anybody deciding to add it.
Why it happens
A diagram is a picture and a rule is a file. Nothing connects a pull request that adds an import to a document in a wiki, and the diagram describes what somebody intended in 2023 rather than what the code does now.
The fix
Generating the actual graph
// bin/module-graph — resolve every use statement to a
// module and emit the edges
$edges = [];
foreach ($this->phpFilesIn('src') as $file) {
$from = $this->moduleOf($file);
foreach ($this->useStatementsIn($file) as $fqcn) {
$to = $this->moduleOfClass($fqcn);
if ($to !== null && $to !== $from) {
$edges["{$from}->{$to}"][] = $file;
}
}
}
$ ./bin/module-graph --format=dot | dot -Tsvg > docs/modules.svg
$ ./bin/graph-diff docs/modules-2023.dot docs/modules-actual.dot
drawn, absent in code:
Reporting -> Billing
Notifications -> Identity
Fulfilment -> Catalogue
Integrations -> Reporting
in code, not drawn:
Catalogue -> Pricing (88 references)
Pricing -> Catalogue (41)
Reporting -> Orders (22)
Notifications -> Orders (14)
Integrations -> Identity (6)
Fulfilment -> Identity (3)Deciding per edge which reality is wrong
Reporting -> Billing drawn, never existed.
an intention from 2023. delete from the diagram.
Catalogue <-> Pricing bidirectional, 129
references. these are one module in practice and
have been since 2024. merge them.
Reporting -> Orders 22 references, all of
them reads through the Orders public API.
legitimate. add to the diagram.
Notifications -> Orders 14 references, of
which 11 reach into internals.
→ 11 violations to fix, 3 edges to draw.
Integrations -> Identity 6 references, all of
them one helper that should be in Shared.
→ move the helper, the edge disappears.Six of the ten discrepancies were the diagram being stale and four were genuine coupling that should not exist, which is roughly the ratio to expect. The bidirectional pair is the interesting one — two modules with edges in both directions and a hundred and twenty-nine references between them are not two modules.
The two that had merged
Catalogue and Pricing, examined:
can either be changed without the other? no,
in 41 of the last 50 pull requests touching
either
do they share domain concepts? yes —
a product's price is part of the product for
every purpose anybody uses
was the split deliberate? yes, in
2023, on the grounds that pricing rules would
become complex enough to warrant separation
did they? no
merged. 129 cross-module references became internal
ones, and the layer rule got simpler.A boundary drawn in anticipation of complexity that did not arrive is the mirror image of the 2023 read model, and the correct response is the same: undo it. Merging cost a day and removed a rule everybody had been working around.
Generating the diagram from the rules
# deptrac.yaml is now the source of truth
ruleset:
Orders: [Shared]
Catalogue: [Shared]
Billing: [Shared, OrdersApi]
Fulfilment: [Shared, OrdersApi]
Reporting: [Shared, OrdersApi, BillingApi]
Notifications: [Shared, OrdersApi]
Identity: [Shared]
Integrations: [Shared, OrdersApi, IdentityApi]
- name: the diagram matches the rules
run: |
./bin/rules-to-dot deptrac.yaml > /tmp/expected.dot
diff -u docs/modules.dot /tmp/expected.dot
- name: the code matches the rules
run: vendor/bin/deptrac --fail-on-uncovered
Two checks, and between them the diagram cannot drift from the rules and the rules cannot drift from the code. The diagram is now generated and less readable than the hand-drawn one — no visual grouping, no annotations, a layout algorithm rather than a person’s judgement — which is the price of it being true.
The –fail-on-uncovered flag
the flag that matters more than the rules: a class in
no layer is a class the rules do not constrain, and by
default that is silent.
our first run with it: 41 uncovered classes.
22 in src/Support/, which was not a declared layer
14 in src/Console/, same
5 genuinely unclassifiable helpers
a rule set with a hole in it passes every check and
enforces nothing on the code that fell through.Verifying it worked
$ vendor/bin/deptrac --fail-on-uncovered
Violations 0, uncovered 0, skipped 4 (baseline)
$ ./bin/rules-to-dot deptrac.yaml | diff -q - docs/modules.dot
# (identical)
$ ./bin/module-graph --format=edges | sort > /tmp/actual
$ ./bin/rules-to-edges deptrac.yaml | sort > /tmp/allowed
$ comm -23 /tmp/actual /tmp/allowed
# (nothing — every edge in the code is permitted)
$ git log --oneline --since=2025-11 -- docs/modules.dot | wc -l
2 # both generated, both in the PR that changed
# the rulesThe diagram changing only in pull requests that change the rules is the outcome — it is now a build artefact rather than a document, which means it cannot be wrong and cannot be edited to express an intention. That second property is a loss and is discussed below.
What this costs
A diagram that is accurate and less readable. The hand-drawn one had grouping, annotations and a layout that reflected how people think about the system; the generated one has a layout algorithm. Onboarding used the old one and now uses a generated picture plus a paragraph, which is worse.
It also removes the ability to draw an intention. The 2023 diagram contained four edges that did not exist, and one of them — Reporting to Billing — was a planned direction rather than an error. There is now nowhere to express that except prose, and prose is where architectural intentions go to be forgotten.