Skip to main content
Story · distributed failure · this one actually happened · 5 min
The modular-monolith claim rests on one rule: no context imports another context’s code. dependency-cruiser enforces it in CI. It had been green on every build since Phase 0.Then someone wrote a deliberate violation to check the rule fired.It did not.

The claim being protected

Arc’s whole topology argument is that six bounded contexts can share one deployable without becoming a monolith, because the boundary is mechanically checked rather than merely documented. Remove the check and the claim collapses into an intention. Every codebase with a “we don’t do that” rule has a directory full of places where someone did, under deadline, with a comment saying // TODO: refactor.
A modular monolith whose boundaries are documented rather than enforced is a monolith with aspirations. The enforcement is the architecture.
So the enforcement had better work.

The probe

Two violations were written, one of each shape a developer could plausibly produce:
Relative import
What you write when you are inside the monorepo and reaching sideways.
Package-name import
What you write when your editor autocompletes it and it looks like every other import in the file.
The first failed the build immediately, as designed. The second passed. No error. No warning. A clean green build with a cross-context import sitting in the source.

Why it passed

The cause is mundane, which is what makes it dangerous.
1

dependency-cruiser resolves imports to file paths

It builds a dependency graph of actual files and applies rules to the edges. To apply a rule to an edge, the edge has to exist.
2

@arc/ledger resolves through node_modules

In a pnpm workspace it is a symlink to services/ledger, whose package.json points its entry at dist/index.js.
3

dist does not exist until after a build

Lint runs before build. The target file is not there.
4

An unresolvable edge is skipped, not failed

dependency-cruiser cannot resolve it, so it does not add the edge to the graph. No edge, no rule evaluation, no violation.
The rule did not fail. It did not apply: which is a much worse failure mode, because a rule that errors gets fixed and a rule that quietly does nothing gets trusted.
The configuration was correct. The rule was correct. The build order made half the input invisible.

Why nobody noticed

Every honest signal pointed the wrong way:
  • CI was green, and green on a rule that exists means the rule is working.
  • Relative violations were being caught, so the rule visibly did something.
  • No cross-context package import existed yet, so there was nothing to catch: the false negative had no false negatives to produce.
  • The configuration reviewed correctly. Reading it would never reveal the problem, because the problem is not in the configuration.
A rule with no violations is indistinguishable from a rule that cannot detect violations. The only way to tell them apart is to produce one on purpose.

The fix

A second, independent layer using a completely different mechanism, ESLint’s no-restricted-imports, which matches on the import specifier string rather than on a resolved file:
It never resolves anything, so build order is irrelevant to it. Neither is sufficient. Together they cover both shapes, and, more importantly, they fail differently, so a change that defeats one is unlikely to defeat the other.
Both probes were re-run and both fired. That verification is the deliverable, not the configuration.

The generalisable lesson

This is not really about dependency-cruiser. It is about a category of control that includes most of what teams rely on:
A missing check is a known gap. A check that silently does not apply is a believed guarantee, and people build on believed guarantees.The same shape appears in permissive CORS matchers, auth middleware not mounted on a route group, alerts on a metric that stopped being emitted, and backup jobs writing to a bucket nobody has restored from.
Nothing in a normal test suite exercises “does the linter catch this?”, that is a property of the pipeline, and pipelines are usually the least-tested code an organisation has.The cheap version costs almost nothing: keep a fixture that violates each rule and assert the tool reports it.
Two checks with the same failure mode are one check with extra runtime. dependency-cruiser depends on module resolution; ESLint pattern matching depends on nothing. That independence is the property worth having.Arc uses the same reasoning in the ledger: the posting engine validates balance, and the database validates again via a constraint trigger. Application logic and a DEFERRABLE INITIALLY DEFERRED trigger fail for entirely different reasons.

The one-line version

An architecture rule you have not tried to break is a rule you do not know you have.

ADR 0002: modular monolith

The decision this rule protects, with its costs stated.

Contexts and events

What contexts use instead of importing each other.