Detecting Circular Dependencies in Local Builds
Find and break circular dependencies in local builds: madge for JavaScript, import-linter and pydeps for Python, and depends_on cycles in Docker Compose stacks.
A local build hangs, a module imports as undefined, or docker compose up reports a dependency loop — all symptoms of a circular dependency, the failure mode that dependency tree visualization exists to surface.
Diagnostic
Run a cycle detector against the relevant graph — module imports for JS/Python, or service ordering for Compose.
#!/usr/bin/env bash
set -euo pipefail
# JavaScript / TypeScript module graph
npx madge --circular --extensions ts,tsx,js src/ || true
# Python import graph
pydeps --show-cycles --no-output app/ || true
# Compose service ordering
docker compose config >/dev/null
Expected BAD output — each tool names the participants in the loop:
✖ Found 1 circular dependency!
1) src/order.ts > src/user.ts > src/order.ts
Cycle found: app.order -> app.user -> app.order
service "app" depends on itself: app -> worker -> app
Root cause
A circular dependency exists when module or service A transitively requires B and B transitively requires A, so there is no valid order in which to load or start them. In JavaScript, the runtime resolves the cycle by handing one module a partially initialized export — usually undefined — which surfaces far from the import as a null-reference crash. In Python, a mid-import cycle raises ImportError: cannot import name because the target name is not yet bound. In Docker Compose, depends_on describes a startup order, and a cycle makes that order unsatisfiable, so Compose refuses to start the stack. In every case the build tool cannot pick a deterministic order, and the fix is to break the cycle by extracting the shared piece or inverting one direction.
Resolution
- Identify the exact edges in the cycle from the detector output.
- Extract the shared types/constants both ends need into a third, dependency-free module.
- Re-point both ends at the new module, removing the back-edge.
- For Compose, replace the back-edge
depends_onwith a runtime healthcheck/retry instead of a startup ordering.
Break a JS/TS cycle by hoisting the shared contract:
// src/types.ts — leaf module, imports nothing from order/user
export interface OrderRef { id: string; userId: string; }
// src/order.ts
import type { OrderRef } from './types'; // was: import { User } from './user'
export function makeOrder(ref: OrderRef) { return { ...ref, status: 'new' }; }
// src/user.ts
import type { OrderRef } from './types'; // both now point at the leaf, cycle gone
export function ordersFor(userId: string): OrderRef[] { return []; }
Enforce the boundary in Python with import-linter instead of relying on convention:
# .importlinter
[importlinter]
root_package = app
[importlinter:contract:no-cycles]
name = No circular imports
type = independence
modules =
app.order
app.user
For a Compose depends_on cycle, break it by making one side tolerate the other being absent at start, gated on health rather than order:
# docker-compose.yml — worker no longer blocks on app; it retries at runtime
services:
app:
build: ./app
depends_on:
worker:
condition: service_started
worker:
build: ./worker
# was: depends_on: [app] -> cycle. Worker reconnects to app via retry loop.
restart: on-failure
Expected output
After breaking the cycles, every detector reports a clean graph:
$ npx madge --circular --extensions ts,tsx,js src/
✔ No circular dependency found!
$ lint-imports
Contracts: 1 kept, 0 broken.
$ docker compose up -d
[+] Running 2/2
✔ Container app-worker-1 Started
✔ Container app-app-1 Started
Prevention
- Add
madge --circular(JS) orlint-imports(Python) as a pre-commit hook and a CI gate so a new cycle fails the pull request. - Run cycle detection inside
make doctorso contributors catch loops locally — see building an onboarding health-check script. - Keep the service graph visible with mapping microservice dependencies for local dev so back-edges are obvious before they merge.
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: no-circular-imports
name: Detect circular dependencies
entry: npx madge --circular --extensions ts,tsx,js src/
language: system
pass_filenames: false
macOS (Docker Desktop):
madgegraph rendering needs Graphviz (brew install graphviz); the--circulartext check works without it. WSL2: run detectors from the Linux filesystem —madgeandpydepswalk thousands of files and are an order of magnitude slower over/mnt/c. Apple Silicon (ARM64): install Graphviz/pydepsfrom a native arm64 toolchain to avoidexec format errorwhen generating dependency images.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- src/ app/ docker-compose.yml # revert the extraction and depends_on edits