skip to content

Dependency cycles usually appear indirectly, from one seemingly harmless import. How would you detect, prevent and govern cycles in a growing codebase?

level: seniorimportance: should knowfreq 35%

answer

  1. Cycles are global; review is local — automate
  2. CI rule that fails the build, not a warning
  3. Declared allowed-dependencies beat generic no-cycle rules
  4. Baseline legacy cycles, expiry not permanent
  5. Component structure jitters — expect splits/merges

basics

~20 s

Don't rely on code review — a loop can span five components and be invisible in a diff. Add an automated dependency check to CI that fails the build on any cycle (ArchUnit/Spring Modulith, dependency-cruiser, import-linter, jdeps, NDepend), fix violations immediately, and keep a documented, owned component map.

solid answer

~50 s

Cycles emerge: someone adds one import and closes a five-hop loop nobody could see in a pull request. So the control must be mechanical. **Detect:** run a graph analysis over the real import/dependency graph in CI and fail on any strongly connected component. Typical tools: ArchUnit `SlicesRuleDefinition...should().beFreeOfCycles()` or Spring Modulith verification (JVM), `dependency-cruiser`/`madge` (JS/TS), `import-linter` (Python), Go's built-in ban, NDepend (.NET), plus Bazel/Gradle graph errors. **Prevent:** enforce declared allowed-dependency lists per module so new edges must be added deliberately; keep a composition root so wiring lives in one place; make the check block merges (with an explicit, expiring baseline if you inherit legacy cycles rather than a permanent suppression). **Govern:** treat the component map as an owned artifact — regenerate the diagram from source, review it periodically, assign each component an owner, and expect the structure to *jitter*: components split and merge as the system grows. New components appearing purely to break cycles is normal, healthy design evolution, not failure.

go deeper

for a junior

Say a tool should check for cycles in CI and name one for your ecosystem; note review can't catch them by eye.

for a middle

Explain why detection must be automated (global property), and describe fixing a detected cycle via DIP inversion or extraction.

for a senior

Add declared per-module allowed-dependency rules, baselines with an expiry for legacy, composition roots, and generated diagrams that can't drift.

for a principal

Frame it as governance: component ownership, evolution/jitter of the structure, ADP alongside SDP/SAP metrics, and the service-level analogue detected from catalogues and traces.

## Why manual vigilance fails A cycle is a **global** property of the graph, but code review is **local**. A reviewer sees `import x.y.Z` added to one file. Whether that closes a loop depends on the other ~200 components. Human review cannot compute reachability. Therefore: automate, or the graph will decay. Secondary reasons cycles sneak in: - **Convenience imports** — a utility grabs one enum from a high-level module. - **Refactors that move classes** between components, silently reversing an edge. - **Test fixtures** reaching into other modules' test helpers. - **Generated code / build scripts / DI configuration** that few people read. - **Growth pressure** — a component splits, and the split halves keep calling each other. ## The detection layer What you actually check: build the directed graph of components from real source dependencies (imports/references/link edges), then look for **strongly connected components** of size > 1 (Tarjan's or Kosaraju's algorithm — most tools do this for you). Report the offending edges, not just the fact of a cycle, so the fix is actionable. Representative tooling by ecosystem: - **JVM**: ArchUnit slice rules (`should().beFreeOfCycles()`), Spring Modulith's `ApplicationModules.verify()`, `jdeps`, Gradle/Maven module graphs, Sonar/Structure101. - **JS/TS**: `dependency-cruiser` (`no-circular` rule), `madge --circular`, ESLint `import/no-cycle`, Nx project-graph constraints. - **Python**: `import-linter` contracts (`forbidden`, `layers`), `pylint` cyclic-import. - **Go**: the compiler rejects import cycles at package level — acyclicity is a language guarantee. - **.NET**: NDepend dependency matrix/cycle detection. - **Build systems**: Bazel, Buck, Pants and Gradle error on cyclic targets by construction. ## The prevention layer 1. **Fail the build.** A warning nobody reads is not a control. Make cycle detection a merge blocker. 2. **Declared allowed dependencies.** Instead of "no cycles" only, declare per component what it *may* depend on (Spring Modulith `allowedDependencies`, import-linter layer contracts, dependency-cruiser rules, Nx tags). This is stronger: it prevents architecturally wrong-direction edges *before* they ever become a cycle, and it makes adding an edge an explicit, reviewable act. 3. **Layering rules.** Express "domain must not depend on infrastructure" as a rule; most cycles violate a layering intent long before they close a loop. 4. **A composition root.** Concentrate concrete wiring in one place so DIP-inverted dependencies have a legitimate home and don't leak back as direct imports. 5. **Baselines with an expiry.** If you inherit cycles, snapshot them so the build goes green and *no new* cycles are allowed, but track the list down to zero. A permanently growing suppression file is the failure mode. ## The governance layer - **Ownership**: every component has an owning team; cross-component edges are a social contract, not just a technical one. An unowned component accumulates cycles. - **Generated diagrams**: render the component graph from source on each build (e.g. PlantUML/Graphviz output) so the picture cannot drift from reality. A hand-drawn architecture diagram is fiction within a quarter. - **Expect jitter**: the component structure cannot be designed correctly up front. Early on, components are shaped for developability (build/test/release convenience); later they shape toward reusability and deployment. Components split, merge and appear specifically to break cycles. Treat that motion as normal, and review the map periodically rather than freezing it. - **Metrics to watch alongside acyclicity**: instability (fan-out ÷ total coupling, SDP: depend in the direction of stability) and abstractness (SAP: stable components should be abstract). ADP tells you the graph is legal; SDP/SAP tell you whether the arrows point somewhere sensible. ## Handling a detected cycle Triage in this order: (1) is one edge obviously wrong-direction? Invert it with DIP. (2) Is the coupling really shared data? Extract a new component. (3) Are these one component in disguise? Merge. Then re-run the check — fixing one cycle can reveal another — and add a rule so it cannot come back. ## Beyond the codebase The same governance applies to services: cycles in synchronous inter-service calls create deploy-order impossibilities, cascading failures and distributed deadlock risk. Detect them from service catalogues/traces rather than imports, and break them with events or extracted contracts.

  • Why is a declared allow-list of dependencies per component stronger than a plain no-cycle check?
    A no-cycle rule only fires once a loop is already closed. An allow-list rejects the wrong-direction edge at the moment it is introduced, keeps intent explicit and reviewable, and also enforces layering that acyclicity alone doesn't.
  • You inherit a codebase with 40 existing cycles. What now?
    Baseline them so the build goes green and no new cycles can be added, publish the list with owners, and burn it down incrementally — highest-traffic or highest-pain loops first. The key is that the baseline shrinks; a growing suppression file means the control has failed.
  • Should the component structure be designed up front?
    No. It is discovered and evolves — early components optimize for build/test/release convenience, later ones for reuse and deployment. Components split, merge and get created specifically to break cycles; expect the map to jitter and regenerate it from source.

saying these in an interview costs you the question

  • "Careful code review is enough" — cycles are a global graph property invisible in a local diff.
  • Leaving the check as a warning, or accumulating an ever-growing suppression list.
  • Maintaining a hand-drawn architecture diagram instead of generating it from source.
  • Assuming the component structure should be finalized up front and never change.
  • Only checking main-source edges while test, generated and build-script edges quietly form loops.
  • Believing acyclicity alone means the architecture is sound — SDP/SAP still decide whether arrows point toward stability and abstraction.

context