Blog

Package dependency cycles: how one clean import quietly breaks your build, your tests, and your team

What a package-level dependency cycle actually is, the mechanical damage it does to builds, testing, and refactoring, how cycles really form, and why near-cycles are the cheapest architectural problem you'll ever fix.

Ask any senior engineer to name the worst architectural smell and dependency cycles will make the shortlist. Ask them to explain mechanically why a cycle is bad (not vibes, mechanisms) and the answers get vague.

That vagueness matters, because cycles never announce themselves. They arrive as one clean, reviewable, entirely reasonable-looking import. We watched it happen in real codebases: a floci PR introduced a new package cycle through its CloudFormation services, and the file diff was unremarkable. So let’s make the case properly.

What a cycle is, and what it does to a graph

A healthy dependency structure is a DAG, a directed acyclic graph. Arrows point one way: features depend on services, services on the domain, the domain on nothing. A cycle exists when you can leave a package by following arrows and arrive back where you started.

The damage isn’t stylistic. The moment a cycle closes, every package on the loop collapses into a single unit. Mathematicians call it a strongly connected component, and your tools treat it exactly that way:

One edge turns three modules into one

Before: a DAG api services domain Change flows one way. Build bottom-up, test each layer alone, refactor a layer at a time. After: one red edge api services domain new Every package can now reach every other. Three modules, one inseparable blob.

The red edge is usually something innocent: domain importing a formatter that happens to live in api. The import is one line. The consequence is graph-wide.

The mechanical costs

Once packages form a cycle, four things degrade. Not eventually, immediately:

What a cycle actually costs you

Builds

Incremental and parallel builds rely on a dependency order. A cycle has no order; the whole loop rebuilds together, every time any member changes.

🧪

Tests

You can't stand up one package without the others on the loop. Unit tests quietly become integration tests; test time and flakiness climb.

🔧

Refactors

"Extract this into a library" and "replace this module" both require a place to cut. A cycle has no seams, so the refactor grows until it's a rewrite, and gets deferred.

👥

Ownership

Team boundaries follow module boundaries. When modules merge into a blob, every change needs everyone's context, and code review slows for all of them.

This is why the Acyclic Dependencies Principle is stated as a hard rule rather than a preference: the costs are structural, and they compound as more packages get pulled into the loop.

And cycles grow. A loop of two packages is easy to absorb a third into; any new edge touching the cycle in the wrong direction extends it. This is the “big ball of mud” attractor: the blob only gains mass.

How cycles actually form

Nobody designs a cycle. In every case we’ve flagged, the cycle arrived through the same lifecycle:

The life of a dependency cycle

1

A convenient import

Someone needs a helper that happens to live one layer up. Importing it takes one line; moving it takes a discussion.

2

A clean review

The diff is small and correct. Nothing in the diff says "this edge points backward." It merges.

3

The near-cycle

The graph now contains a path that's one edge away from a loop. Nobody knows, because nobody is looking at the graph.

4

The closing edge

Months later, an unrelated PR (increasingly, an AI-written one) adds the edge that closes the loop. That diff looks clean too.

Step 3 is the one to care about. In our scans, HikariCP showed several of these near-cycle seeds, and an EF Core PR shipped one alongside an edge that skipped three layers. Caught at step 3, the fix is trivial. Caught at step 4, you're already rebuilding the loop together.

Note what’s absent from that lifecycle: malice, incompetence, or bad code. Every individual step is locally reasonable. Cycles are an emergent property of many good diffs, which is precisely why diff-by-diff review doesn’t catch them, and why the problem accelerates as AI tools multiply PR volume.

Finding cycles before they close

You don’t need to buy anything to start. Free tools will list package cycles in most ecosystems: jdeps ships with the JDK, madge (madge --circular) covers JS/TS, pydeps covers Python. Rule-based guards like ArchUnit or dependency-cruiser can fail the build when a known boundary is crossed. If you do nothing else, run one of these quarterly; we’ve collected the full workflow in our architecture review checklist.

Two gaps remain, and they’re the expensive ones:

  • Timing. A quarterly scan tells you a cycle exists after three months of code has been built on top of it. The cheap moment to act, the PR that created the risk, is long merged.
  • Near-cycles. Listing existing cycles is easy; recognizing that this specific new edge moves the graph one step from a loop requires comparing the dependency graph before and after every PR. No linter rule expresses that.
A cycle is cheapest to fix in the review of the PR that seeds it, and invisible in exactly that review. That inversion is the whole problem. The information you need at step 3 lives in the graph, and the diff you're reviewing doesn't contain the graph.

That per-PR graph comparison is what Striff does: it models every pull request as a change to the dependency graph and flags a new cycle or a near-cycle seed in the PR that introduces it, next to the diff, before merge. Install the browser extension and it runs on your next pull request, no config needed.