How to Find and Fix Circular Dependencies in Your Codebase

Circular dependencies cause load-order bugs, slow builds and tangled designs. This guide covers detection tools per language, five refactoring patterns, and CI rules that keep cycles out.

By The OpenVisio Team · · 12 min read

A circular dependency is a loop in your import graph: module A imports B, B imports C, and C imports A. In some languages the compiler refuses to build it. In others it builds fine and fails at runtime with an undefined that should have been a function, or an ImportError about a partially initialized module. Even when nothing crashes, cycles make code harder to test in isolation, harder to reuse, and slower to build incrementally, because every module in the loop has to be treated as one unit.

This guide covers how to detect cycles in the major ecosystems, how to decide which ones matter, five refactoring patterns that break them, and how to stop new ones from appearing.

Key takeaways

  • Go and Rust (between crates) reject import cycles at compile time. JavaScript, TypeScript, Python, Java and C# allow them, so you need tooling.
  • Detect with madge or dependency-cruiser (JS/TS), eslint-plugin-import's import/no-cycle, import-linter or pydeps (Python), ArchUnit or jdeps (Java).
  • Prioritize cycles that cross folder or package boundaries. A two-file cycle inside one module is usually a minor issue; a cycle spanning three top-level folders is an architecture problem.
  • The common fixes: extract the shared piece, invert the dependency with an interface, merge modules that are really one, move code to its real owner, or replace a direct call with an event.
  • Freeze the current state in CI first so the count can only go down, then fix cycles in order of impact.

Why cycles hurt

Initialization order bugs. In CommonJS and ES modules, when A imports B and B imports A, one of them sees the other before it has finished evaluating. In CommonJS you get a partially filled module.exports object; in ES modules you can get a ReferenceError from the temporal dead zone (Cannot access 'X' before initialization). In Python you get ImportError: cannot import name 'X' from partially initialized module 'y' (most likely due to a circular import). These bugs often appear only when an import order changes, which makes them look random.

No independent testing or reuse. You cannot load one module in the cycle without loading all of them. Unit tests pull in far more than they need, and extracting a module into its own package becomes impossible without first breaking the loop.

Slower builds and worse caching. Build tools and bundlers treat a strongly connected component as a single unit. Change any file in a ten-module cycle and all ten are rebuilt or invalidated.

Design erosion. Cycles usually mean two concepts that were supposed to be separate have become entangled. Each new cycle makes the next one easier to add.

Detecting cycles

Go and Rust

Go refuses to compile an import cycle between packages:

package example.com/shop/orders
	imports example.com/shop/billing
	imports example.com/shop/orders: import cycle not allowed

Cargo likewise rejects cyclic dependencies between crates. Within a single crate, Rust modules can refer to each other freely, so module-level tangles are possible but do not cause initialization bugs. If you work only in these languages, the compiler is your cycle detector, and the refactoring patterns below still apply when you hit the error.

JavaScript and TypeScript

madge is the quickest check:

npx madge --circular --extensions ts,tsx --ts-config tsconfig.json src/

It prints each cycle as a chain of files. Add --warning to see skipped files, and render the cycles with --image cycles.svg (requires Graphviz).

dependency-cruiser is more configurable and better suited to CI:

npx depcruise --init          # creates .dependency-cruiser.js with a no-circular rule
npx depcruise src --config .dependency-cruiser.js

The generated config includes a rule like this:

{
  name: 'no-circular',
  severity: 'error',
  comment: 'Circular dependencies make modules impossible to load or test independently.',
  from: {},
  to: { circular: true },
}

For feedback in the editor, eslint-plugin-import has import/no-cycle:

// eslint.config.js (flat config)
import importPlugin from 'eslint-plugin-import'

export default [
  {
    plugins: { import: importPlugin },
    rules: {
      'import/no-cycle': ['error', { maxDepth: 10, ignoreExternal: true }],
    },
  },
]

import/no-cycle can be slow on large codebases because it resolves the full graph per file. A common setup is to run dependency-cruiser in CI and keep no-cycle off in the editor, or limit maxDepth.

A TypeScript-specific note: import type { Order } from './orders' is erased at compile time and cannot cause a runtime cycle. dependency-cruiser can distinguish these (via dependencyTypesNot: ['type-only'] in the rule's to section), which lets you focus on cycles that actually execute.

Python

import-linter checks contracts against the import graph of a package. A layers contract forbids imports from lower layers to higher ones, which rules out cycles between those layers:

# pyproject.toml
[tool.importlinter]
root_package = "shop"

[[tool.importlinter.contracts]]
name = "Layered architecture"
type = "layers"
layers = [
    "shop.api",
    "shop.services",
    "shop.domain",
]

[[tool.importlinter.contracts]]
name = "Feature modules are independent"
type = "independence"
modules = [
    "shop.orders",
    "shop.billing",
    "shop.catalog",
]
pip install import-linter
lint-imports

For a visual check, pydeps can highlight cycles:

pydeps shop --show-cycles -o cycles.svg

Java and the JVM

ArchUnit expresses the rule as a unit test:

@AnalyzeClasses(packages = "com.example.shop")
class ArchitectureTest {
    @ArchTest
    static final ArchRule noCyclesBetweenFeatures =
        slices().matching("com.example.shop.(*)..").should().beFreeOfCycles();
}

matching("com.example.shop.(*)..") treats each top-level package under shop as a slice, so the test fails on cycles between features but not within one. jdeps (jdeps -verbose:package target/classes) gives a package dependency listing you can inspect for loops, and IntelliJ's "Analyze Cyclic Dependencies" action shows them interactively.

Deciding which cycles to fix first

A mature codebase may report dozens or hundreds of cycles. Do not try to fix them all. Rank them:

  1. Cycles causing bugs. Anything involved in an initialization-order error, a flaky import, or a test that only passes when run with others.
  2. Cycles crossing boundaries. Cycles between top-level folders, packages or features. These block extraction, ownership splits and independent builds.
  3. Large strongly connected components. One cycle involving 40 files is worse than 10 cycles of two files each.
  4. Cycles in frequently changed code. A cycle in a hotspot costs you every week; a cycle in code nobody touches costs almost nothing.
  5. Small local cycles. Two files in the same folder importing each other. Fix opportunistically.

Five patterns for breaking cycles

Most cycles fall into one of a few situations. Identify which edge in the loop is "wrong" (usually the one pointing from a lower-level or more general module to a higher-level or more specific one) and apply the matching pattern.

1. Extract the shared piece

Two modules import each other because each needs something small from the other: a type, a constant, a helper. Move that piece into a new module both can depend on.

Before:  orders  <-->  billing          (orders needs Invoice type, billing needs OrderId)
After:   orders  -->  shared-types  <--  billing

This is the most common fix. Be careful not to create a new grab-bag common module; name the extracted module after what it contains.

2. Invert the dependency with an interface

A lower-level module calls up into a higher-level one. Define an interface in the lower module, have the higher module implement it, and inject the implementation.

// billing/ports.ts (billing owns the interface it needs)
export interface OrderLookup {
  totalFor(orderId: string): Promise<number>
}

// billing/invoice.ts
import type { OrderLookup } from './ports'

export async function createInvoice(orderId: string, orders: OrderLookup) {
  const total = await orders.totalFor(orderId)
  // ...
}

// orders/billing-adapter.ts (orders depends on billing, not the reverse)
import type { OrderLookup } from '../billing/ports'
import { getOrder } from './repository'

export const orderLookup: OrderLookup = {
  async totalFor(id) {
    return (await getOrder(id)).total
  },
}

Billing no longer imports orders. The edge now points one way, and billing can be tested with a fake OrderLookup.

3. Merge modules that are really one

Sometimes two files are split for size but are one concept, and every change touches both. A cycle between them is a hint that the split was arbitrary. Merging them, or moving the cyclic parts into one file, is a legitimate fix.

4. Move code to its real owner

A function lives in the wrong module. formatInvoiceLine sits in orders/utils.ts and imports from billing, while billing imports other things from orders/utils.ts. Move the function into billing and the cycle disappears. This is often the right answer for cycles through utils or helpers files.

5. Replace a direct call with an event

When a module needs to notify another that something happened, and the other module already depends on the first, publish an event instead of calling directly.

# orders/service.py: before, orders imported notifications, which imported orders
from shop.events import bus

def complete_order(order):
    order.status = "complete"
    bus.publish("order.completed", order_id=order.id)
# notifications/handlers.py: notifications subscribes; orders no longer imports it
from shop.events import bus
from shop.orders.repository import get_order

@bus.subscribe("order.completed")
def send_confirmation(order_id):
    order = get_order(order_id)
    ...

Events are not free: they make control flow harder to follow and hide the edge from static tools, as discussed in call graphs explained. Use them when the relationship is genuinely "notify", not to hide a dependency you should have removed.

A last resort: lazy imports

Moving an import inside a function (Python) or using a dynamic import() (JavaScript) makes the runtime error go away without fixing the design. It is acceptable as a temporary patch for a production bug. Leave a comment and a ticket, because the cycle is still there.

Keeping cycles out

Fixing cycles is pointless if new ones arrive faster. Lock the current state first, then ratchet down.

Baseline the existing violations. dependency-cruiser can record current violations and only fail on new ones:

npx depcruise src --config .dependency-cruiser.js --output-type baseline > .dependency-cruiser-known-violations.json
npx depcruise src --config .dependency-cruiser.js --ignore-known

import-linter supports ignore_imports on each contract for the same purpose, and ArchUnit has FreezingArchRule to record existing violations in a store and fail only on new ones.

Run the check on every pull request. A cycle introduced in review is a five-minute fix. The same cycle discovered six months later can take days.

Track the count. Put the number of known cycles somewhere visible and make sure it only goes down.

Write down the intended direction. Most cycles happen because nobody agreed which way the edge should point. A short layering rule, described in module boundaries and coupling, answers that question before it comes up.

Seeing cycles in context

A list of file chains from madge tells you a cycle exists. It does not show you how the cycle sits in the rest of the architecture, which matters when choosing which edge to cut. A graph view helps: OpenVisio's Explore Architecture view shows how modules and groups connect, which makes it easier to see which edge in a loop points the "wrong" way. For coding agents doing the refactor, the get_dependents MCP tool lists what imports a file, so the agent can check that moving a function will not simply create a new loop somewhere else.

circular dependenciesmadgedependency-cruiserimport-linterrefactoring

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading