Boundaries drawn on a whiteboard, and the test that checks them

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 rules

The 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.