DEV Community

Augusto Occhiuzzi
Augusto Occhiuzzi

Posted on Originally published at archynt.com

How to Detect Circular Dependencies in Java, TypeScript, Python and Go

A circular dependency is rarely written on purpose. It shows up one reasonable import at a time — a service that needs a helper, a helper that needs a constant from the service — until three classes can no longer be changed without the other two. This guide covers how to find them in Java, TypeScript, Python and Go, how to break them, and how to stop new ones in code review.

Why they matter

  • Changes ripple. Every class in the cycle can be affected by a change to any other.
  • Testing gets heavy. To test one piece you have to construct the whole cycle.
  • Runtime bugs. In Node.js a circular require or import can hand you a module that hasn't finished initialising, so a value is undefined at load time. Spring Boot has refused circular bean references by default since 2.6.
  • Splitting becomes impossible. A cycle can't be extracted into separate services or packages without breaking it first.

Java

ArchUnit lets you write the rule as a unit test, failing the build when packages form a cycle:

slices().matching("com.myapp.(*)..")
    .should().beFreeOfCycles()
    .check(importedClasses);
Enter fullscreen mode Exit fullscreen mode

In Spring, a startup failure about circular references between beans is the framework telling you the same thing at runtime. Resist re-enabling spring.main.allow-circular-references; fix the cycle.

TypeScript and JavaScript

madge builds the import graph and lists cycles; eslint-plugin-import catches them while you edit:

npx madge --circular --extensions ts src/
Enter fullscreen mode Exit fullscreen mode
// .eslintrc
"rules": { "import/no-cycle": "error" }
Enter fullscreen mode Exit fullscreen mode

Python

Pylint reports import cycles as cyclic-import (R0401), and pydeps can draw the module graph with the cycles highlighted:

pylint --disable=all --enable=cyclic-import mypackage
pydeps mypackage --show-cycles
Enter fullscreen mode Exit fullscreen mode

Moving an import inside a function makes the error go away but keeps the cycle; treat it as a temporary patch.

Go

The compiler does the package-level work for you: an import cycle between packages is a build error. Cycles between types inside one package are still allowed, and a large package can hide a tangle of structs that all depend on each other.

How to break a cycle

  1. Invert one dependency. Have A depend on an interface it owns, and let B implement it.
  2. Extract the shared part. If both sides use the same types or helpers, move them into a third module both depend on.
  3. Use an event instead of a call. When B only needs to know that something happened in A, A can publish an event rather than calling B.
  4. Merge them. If two classes can't work without each other, they may really be one.

Stop new cycles in pull requests

Language tools check one language and usually one level — packages or files. Archynt builds a class-level graph for Java, TypeScript, Python and Go and finds every cycle in it. On a pull request it analyses both the base and the head commit and reports only what the PR changed: a new cycle of four or more classes fails the check, a smaller one warns. The same analysis flags god classes, single points of failure and dead-code candidates.

For public repositories it's a free GitHub Action — no account and no token shared:

name: Archynt architecture scan
on: pull_request

jobs:
  archynt-scan:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: archynt/pr-check-action@v1
        with:
          language: java # java | go | node | python
Enter fullscreen mode Exit fullscreen mode

Private repositories use PR checks through the GitHub App, and the class diagram shows where each cycle sits in the codebase.

FAQ

What is a circular dependency?
Two or more modules or classes that depend on each other, directly (A uses B and B uses A) or through a chain (A → B → C → A). None of them can be understood, tested or changed in isolation.

Why are circular dependencies bad?
They couple pieces that should be independent: a change in one ripples through the whole cycle, tests need the entire cycle wired up, and in some runtimes they cause real bugs, such as half-initialised modules in Node.js or bean creation failures in Spring.

Does Go allow circular imports?
No. The Go compiler rejects import cycles between packages with an "import cycle not allowed" error. Types inside the same package can still depend on each other in a cycle.

How do I fix a circular dependency?
Usually by inverting one dependency through an interface, extracting the shared part into a module both can depend on, or replacing a direct call with an event. Merging the two pieces is the right answer when they were never really separate.


Originally published on archynt.com.

Top comments (0)