Dependency cycles usually appear indirectly, from one seemingly harmless import. How would you detect, prevent and govern cycles in a growing codebase?
answer
- Cycles are global; review is local — automate
- CI rule that fails the build, not a warning
- Declared allowed-dependencies beat generic no-cycle rules
- Baseline legacy cycles, expiry not permanent
- Component structure jitters — expect splits/merges
basics
~20 sDon'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 sCycles 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
Say a tool should check for cycles in CI and name one for your ecosystem; note review can't catch them by eye.
Explain why detection must be automated (global property), and describe fixing a detected cycle via DIP inversion or extraction.
Add declared per-module allowed-dependency rules, baselines with an expiry for legacy, composition roots, and generated diagrams that can't drift.
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.