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

  1. Identify the exact edges in the cycle from the detector output.
  2. Extract the shared types/constants both ends need into a third, dependency-free module.
  3. Re-point both ends at the new module, removing the back-edge.
  4. For Compose, replace the back-edge depends_on with 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

  1. Add madge --circular (JS) or lint-imports (Python) as a pre-commit hook and a CI gate so a new cycle fails the pull request.
  2. Run cycle detection inside make doctor so contributors catch loops locally — see building an onboarding health-check script.
  3. 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): madge graph rendering needs Graphviz (brew install graphviz); the --circular text check works without it. WSL2: run detectors from the Linux filesystem — madge and pydeps walk thousands of files and are an order of magnitude slower over /mnt/c. Apple Silicon (ARM64): install Graphviz/pydeps from a native arm64 toolchain to avoid exec format error when 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