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
requireor import can hand you a module that hasn't finished initialising, so a value isundefinedat 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);
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/
// .eslintrc
"rules": { "import/no-cycle": "error" }
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
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
- Invert one dependency. Have A depend on an interface it owns, and let B implement it.
- Extract the shared part. If both sides use the same types or helpers, move them into a third module both depend on.
- 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.
- 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
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)