Two modules each reference the other, forming a cycle in the dependency graph. What does a cycle actually cost you, and which languages refuse to build one versus merely letting it hurt later?
answer
- SCC condensation: the cycle is the real unit
- No topological order, no bottom-up build or test
- Go: import cycle = hard error, no flag
- Python: partially initialised module, entry-point dependent
- C# forbids assembly cycles; Java only in JPMS
basics
~20 sA cycle fuses its members into one unit: everything in a strongly connected component must be built, tested, versioned and understood together. Go rejects import cycles at compile time with no override; Java and C# allow package-level cycles freely; Python allows the import and fails at runtime on a partially initialised module.
solid answer
~60 sFormally, take the strongly connected components of the dependency graph: they collapse to a DAG, and the component - not the file - is the real unit of build, test and release. Acyclic means a topological order exists, so you can compile and reason bottom-up. Where languages diverge is the enforced granularity: - **Go**: import cycles are a hard compile error with no escape hatch. The idiomatic fix is to move the interface to the consumer, which structural typing makes free. - **Python**: circular imports are allowed; the second import returns a partially initialised module, so `from x import y` at top level raises ImportError while `import x` plus late attribute access survives - a failure that depends on entry point. - **Java and C#**: class and package cycles compile fine; only Java's JPMS forbids cyclic `requires`, and only .NET forbids circular *assembly* references - which is why C# solutions split into projects far earlier than Java splits packages. - **Rust**: modules inside a crate may be mutually recursive, but circular *crate* dependencies are rejected, so "extract a trait crate" is a standard move.
code
go · 6 lines// package a imports b; package b imports a
$ go build ./...
import cycle not allowed
package example/a
imports example/b
imports example/ago deeper
Know what a cycle is, why it stops you from building or testing a unit on its own, and the three ways out: extract, invert, merge.
Bring the SCC framing and at least two languages with different enforcement points, including the Python partial-initialisation failure mode.
Discuss enforced granularity as a design force - why .NET solutions fragment into projects, why Go codebases grow consumer-side interfaces - and how to enforce acyclicity in a language that will not.
Treat the dependency graph as an architectural asset: choose where boundaries are defended, accept SCCs where they are honest, and put automation on the line so drift fails a build rather than a review.
## A dependency graph, and what a cycle does to it Draw a node per unit (class, file, package, crate, assembly) and an edge from A to B when A needs B to compile or run. If that graph is acyclic, it has a **topological order**: some unit depends on nothing, some unit depends only on that, and so on. Every useful property follows from that order. You can build bottom-up. You can test a unit with only its dependencies present. You can read a unit and know the set of things it can be affected by. You can release a leaf without releasing its dependents. A cycle destroys all of that at once, for every member of the cycle. The right formal object is the **strongly connected component**: the maximal set of nodes each reachable from the others. Condense each SCC to a single node and the graph becomes a DAG again - which tells you exactly what a cycle costs. The SCC is now the true unit. Three classes in a cycle are, for build, test, comprehension, release and reuse purposes, one class with three names. You cannot extract one for reuse, you cannot understand one without the others, you cannot mock across the boundary because there is no boundary, and a change to any of them rebuilds all of them. That is why cycles are worth arguing about even when the compiler is indifferent - and compilers differ wildly in how indifferent they are. ## Who enforces acyclicity, and at what granularity **Go** enforces it at the package level, absolutely. `import cycle not allowed` is a build failure and there is no flag, annotation or ordering trick that permits it. That single decision shapes Go codebases: because you cannot patch over a cycle, you must break it, and the standard way is to move the interface to the consumer. Go's structural interfaces make that cost-free - the provider does not import the consumer to satisfy its interface - so the cycle usually disappears entirely rather than being routed around. The price is real too: you sometimes create packages that exist only to be depended upon by both sides, and `internal/` packages to keep them private. **Python** allows the cycle and defers the pain to runtime. Importing a module inserts a partially initialised module object into `sys.modules` before its body finishes executing, so a circular import may succeed or fail depending on *which* module was entered first and *how* you imported. `from b import helper` at the top of `a.py` fails with "cannot import name ... from partially initialized module" when `b` is midway through importing `a`; the same dependency written as `import b` and used later inside a function body works, because the attribute is resolved after both bodies have run. The result is a defect class whose reproduction depends on the entry point - a script, a test runner and a web server can each see a different outcome. **Java** compiles mutually referential classes and packages without complaint; javac resolves the cycle within a compilation unit set. Only the module system (JPMS) forbids cyclic `requires` between named modules, so enforcement exists but at a granularity most projects never reach. Tooling fills the gap - ArchUnit rules, jdepend, Spring Modulith's verification - which tells you the constraint is considered valuable even where the language does not impose it. **C#/.NET** is the interesting middle: inside an assembly, cycles among classes and namespaces are free, but a circular *assembly* reference is a hard error. So the enforced unit is the deployment artefact. This measurably changes design habits - .NET solutions tend to split into many projects precisely because that is the only line the compiler will defend, whereas a Java or Kotlin codebase can accumulate package cycles inside one jar indefinitely. **C++** adds a dimension the others do not have: physical versus logical dependency. Headers that include each other need include guards and forward declarations; the pimpl idiom removes a *compile-time* edge while keeping the runtime relationship, purely to stop a header change rebuilding the world. Here a cycle's dominant cost is build time, and the tools to break it are physical. **Rust** enforces at the crate level - cargo rejects a circular crate dependency - while permitting mutually recursive modules within a crate. That draws the same line .NET draws, at the unit of publication. ## Breaking a cycle Three moves cover nearly everything, and they are language-independent even though the pressure to use them is not. **Extract**: pull the shared thing both sides need into a new unit both depend on, turning A-B into A-C and B-C. **Invert**: replace one edge with an edge to an abstraction, so the arrow reverses (which language mechanics make this cheap or expensive is a separate question in this topic). **Merge**: if two units genuinely cannot be separated, admit the SCC and make it one unit with one name, which at least stops pretending there is a boundary. The move to avoid is the local patch - a late import inside a function in Python, a forward declaration in C++, a setter-based wiring step in Java - which silences the symptom and leaves the SCC in place.
- Java's compiler accepts package cycles. Why do teams still enforce acyclicity with tools like ArchUnit or Spring Modulith?Because the costs are architectural, not compilational. A cycle means the packages cannot be understood, tested, extracted or released independently, and it is the mechanism by which a modular codebase quietly becomes one lump. Since javac will not defend the boundary, the test suite has to: a rule that fails the build on a new cycle is cheap and catches the drift on the day it happens.
- A Python circular import is fixed by moving the import inside a function. Has the cycle gone away?No. The graph still has the cycle; you have only delayed resolution until after both module bodies have executed, so the ImportError no longer triggers. Nothing about independent testing, reuse or comprehension improved, and the next top-level import of the same pair fails again. Treat it as a stopgap and follow it with an extract or an inversion.
- Is a cycle between two classes in the same package as bad as one between packages?Much less so. Mutually recursive classes inside one module are often legitimate - a node and its visitor, a parser and its AST - because the module is already the unit of build, release and comprehension, so the SCC does not grow beyond an existing boundary. The damage scales with the boundary the cycle crosses: package, artefact, team, repository.
saying these in an interview costs you the question
- Saying a cycle is fine because the compiler accepts it - Go, Rust and .NET reject it at their respective granularities precisely because acceptance is not the same as harmless.
- Treating a Python function-level import as a fix rather than as deferral of the symptom.
- Believing a forward declaration in C++ removes the dependency; it removes the compile-time edge only.
- Breaking a cycle by merging two units into one by default, when extracting the shared part would have preserved a genuine boundary.
- Not knowing that .NET enforces at assembly level while Java enforces only at JPMS module level, then assuming both ecosystems have the same habits.