StructureGENERALLANGUAGE-SPECIFICCONTESTED

Circular Dependencies

A depends on B depends on C depends on A. What breaks is reasoning first, then initialisation order, then testability, and only last the build.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

My build tolerates cycles, so why does a dependency cycle matter?

The requirement

A new engineer needs to understand what happens when an order is placed. Order imports Pricing, Pricing imports Customer, and Customer imports Order. There is no entry point to start reading from, and a change to any of the three has an unclear blast radius.

The obvious build

The build handles it and nothing is broken. Cycles are a stylistic concern, and the lint rule that flags them is noise.

Why it breaks

The reasoning cost is immediate and permanent: you cannot understand Order without Pricing, or Pricing without Customer, or Customer without Order. The three modules are one module with three files, and the unit you must hold in your head is the whole cycle (Local Reasoning).

How it breaks as requirements change
  • The reasoning cost is immediate and permanent: you cannot understand Order without Pricing, or Pricing without Customer, or Customer without Order. The three modules are one module with three files, and the unit you must hold in your head is the whole cycle (Local Reasoning).
  • Initialisation order becomes load-order dependent. In languages with module-level side effects — a constant computed at import time, a registry populated on load — a cycle means one of the three sees an incomplete version of another, and which one depends on who imported first.
  • Nothing in the cycle can be tested alone. Instantiating Order drags in Pricing and Customer, so every unit test is an integration test and every test failure has three suspects (Testing as Design Feedback).
  • The build eventually notices: the three cannot be compiled, cached, packaged or extracted separately, so the cycle sets a floor on incremental build times and blocks any future split (The Modular Monolith).
  • Cycles grow. A cycle of three becomes a cycle of eight, because once the boundary is meaningless there is no reason for the next import not to cross it (Dependency Cycles).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The language and bundler both allow cycles, so nothing fails today and there is no forcing event.
  • The three modules are heavily used, so any restructuring touches a lot of callers (Stability and Dependency Direction).
  • Two of the three are owned by different teams, so the fix is a negotiation (Code Ownership).
Invariants
  • It must be possible to load and initialise the system in some order where every module's dependencies are ready before it runs.
  • It must be possible to describe what a module does without describing the whole cycle.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The concept that all three need — typically an identifier, a value type or an event — has to be owned by exactly one place, and usually it is currently owned by none (Breaking Cycles).
  • Each module owns declaring which direction it depends in, and that direction is a design decision rather than an accident of who needed what first.
  • Whoever adds the import that closes a cycle owns noticing, which is only realistic if a tool tells them (What to Automate Out of Review).
Boundaries
  • A cycle proves the boundary is not where a change stops: a change to any member can reach every other, so the three-way split is documentation rather than structure (Decomposition by Folder).
  • The correct boundary is usually around the shared concept the cycle is passing back and forth, which nobody has named yet (What an Abstraction Actually Is).
  • Sometimes there is no boundary at all and the honest answer is one module — merging is a legitimate fix and the least-used one (Breaking Cycles).

The graph, and the concept hiding in it

Draw the cycle and then ask what each edge is actually for. In almost every real case the edges exist to carry one small piece of knowledge in each direction, and that knowledge is a concept nobody has named.

Here, Pricing needs a customer's tier and Customer needs to know what an order was worth. Neither of those is really about the other module; both are about a third thing — the loyalty relationship — which does not exist yet.

  • The third edge is the one that closed the cycle, and it was added by someone who needed one number, in a hurry, and had no reason to think about the graph.
  • Extracting a Loyalty module that owns tier and lifetime spend removes two edges at once, and both remaining edges point at it (Breaking Cycles).
  • The alternative fix is to notice that Order and Pricing always change together and merge them — which is legitimate and is the option nobody considers (Module Granularity).
Three modules, one missing concept
needs a priceneeds the tierneeds lifetime spendOrderThe concept neither module owns: tier and spendPricingCustomer
UserLLMAgentToolDataDecisionHumanGuardrail

What breaks, in the order it breaks

The costs arrive in a predictable sequence, and the reason cycles survive is that the first two are invisible in code review while the last one may never arrive at all.

Note the second row especially. Load-order bugs are among the most confusing failures in software, because the module that fails is not the module that changed and the behaviour depends on which entry point started the process.

A cycle, from the cheapest symptom to the most expensive
TriggerSymptomCauseResponse
Reading any memberYou cannot describe one module without the other twoThe cycle is one reasoning unit wearing three namesThis is the first and largest cost, and it is paid by every reader forever (Local Reasoning).
Import-time initialisationA module-level constant is undefined, only under one entry pointA cycle forces one member to observe another mid-initialisationMove initialisation out of module bodies, and break the cycle. Language-dependent in severity, universal in mechanism (Hidden Global State).
Writing a unit testTesting one module loads all three and needs a databaseThe import graph pulls in the whole cycle regardless of injectionThe test difficulty is the design feedback; do not fix it with more mocks (Testing as Design Feedback).
Changing one memberReview and regression cover all three every timeBlast radius is the cycle, not the fileBreak it, or stop pretending the boundary contains anything (Change Amplification).
Incremental build or cachingTouching any member rebuilds and re-tests all of themThe build cannot order or cache a cycleA real cost at scale, and usually the one that finally gets the work funded — though the reasoning cost was always the larger bill.
Trying to extract a package or serviceThe extraction pulls in the whole cycle and stallsA cycle cannot be cut without a redesignThis is why acyclicity is worth enforcing before you need it — it preserves an option (The Modular Monolith).

The bug you only get in production

LANGUAGE-SPECIFICThis exact failure needs a language that runs module bodies on import — JavaScript, TypeScript, Python, Ruby. In Java or C# the equivalent is static initialiser order, which is rarer and better diagnosed; in Go the compiler rejects the import cycle outright, so this code never runs at all. The reasoning and testability costs of the cycle are identical in every one of them, which is why the argument does not rest on this bug.

In any language that executes module bodies at import time, a cycle means somebody sees a half-built module. Which somebody depends on the import order, which depends on the entry point, which is why this reproduces in the worker and not in the test suite.

The fix in the snippet is not the interesting part. The interesting part is that neither file looks wrong, no tool reported anything, and the symptom appeared in a module that had not been edited for a year.

A constant that is sometimes undefined
1// pricing.ts
2import { TIERS } from './customer'
3export const MAX_DISCOUNT = Math.max(...TIERS.map(t => t.discount))
4// ^ runs at import time
5
6// customer.ts
7import { MAX_DISCOUNT } from './pricing'
8export const TIERS = [
9 { name: 'gold', discount: 0.2 },
10 { name: 'silver', discount: 0.1 },
11]
12export function cap(d: number) { return Math.min(d, MAX_DISCOUNT) }
13
14// Entry point A imports pricing first -> TIERS is undefined
15// -> MAX_DISCOUNT is NaN
16// Entry point B imports customer first -> everything works
17// Same code. Different binary behaviour per entry point.

Two independent problems are visible here and both matter. The cycle is one; module-level computation that depends on another module is the other, and it is what turns the cycle from a reasoning cost into a runtime failure. Extracting the tier table into its own module that imports nothing fixes both at once, which is the usual shape of a good cycle fix (Breaking Cycles).

How to build it

Most important first.

  • Draw the graph before arguing about it. Most teams are surprised by their own cycles, and cheap tooling — a language server, madge, import-linter, go list, an architecture test — produces it in minutes.
  • Find the concept being passed around the loop. In the example it is "what a customer's tier means for a price", which belongs to neither Order nor Customer (Breaking Cycles).
  • Prefer moving the shared concept to its own module over introducing an interface, because an interface with one implementation leaves the coupling intact and adds a hop (Speculative Generality).
  • Where the cycle is genuinely two halves of one thing, merge them. Two modules that always change together and depend on each other were never two modules (Over-Decomposition).
  • Enforce acyclicity with a build-failing check once you are clean, because a cycle costs nothing to add and a lot to remove later (What to Automate Out of Review).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • While the cycle exists, any change to any member requires understanding all three, and the review has to cover all three regardless of which file changed.
  • Testing costs are permanent: no member can be exercised alone, so every suite is slow and every failure is ambiguous.
  • Breaking the cycle costs one focused refactor — typically extracting one small module and updating imports — and the cost rises roughly with the number of members, which is an argument for doing it while it is three.
  • After breaking it, each module can be changed, tested, cached and eventually extracted alone. The last of those is what makes it a structural fix rather than a tidy-up (The Modular Monolith).
What the recommended approach costs
  • Breaking a cycle usually adds a module, and more modules means more files, more names and more navigation (Over-Decomposition).
  • The acyclicity rule occasionally forbids an import that would genuinely have been the simplest expression of something, and the workaround is worse to read.
  • Enforcement tooling produces false alarms on generated code, test fixtures and framework conventions, and maintaining the exception list is ongoing work.

What can go wrong

Failure modes
  • An import-time constant reads as undefined in production because a different entry point changed the load order, and the failure appears in a module that was not modified.
  • The cycle is "fixed" with a lazy or deferred import inside a function, which hides it from the tooling and leaves every other cost in place.
  • An interface is introduced to break the compile-time edge, and the two modules still change together every time — the graph is acyclic and the coupling is untouched (Dependency Inversion, Critically).
  • The mitigation fails on its own terms: a types/ or shared/ module is created to hold everything two cycles needed, and it becomes a dependency of everything with no coherent reason to change (The Common Module).
Dependencies, and their direction
  • The cycle is itself the dependency problem: every member transitively depends on every other, so the effective fan-out of each is the whole cycle (Fan-in and Fan-out).
  • Anything depending on one member depends on all of them, which is how a cycle spreads its rebuild and re-test cost outward (Change Amplification).
  • Test doubles cannot cut the cycle: doubling Customer in an Order test still loads Pricing, because the import graph does not care what you injected (Test Doubles, Precisely).
Misreads
  • "Our language allows cycles, so they are fine." Compilation is the last cost, not the first. Reasoning, initialisation and testability all degrade before the compiler ever complains, and in most languages it never will (Local Reasoning).
  • "A lazy import fixes it." It moves the edge from load time to call time. The modules still change together, still cannot be tested apart, and the tooling now cannot see the cycle to warn you (Temporal Coupling).
  • "Add an interface to invert one edge." Sometimes right, and often it produces a one-implementation interface with the same co-change. Ask whether the two modules still change together afterwards; if they do, nothing was fixed (Dependency Inversion, Critically).
  • "Cycles between classes are the same as cycles between packages." Two classes in one module that reference each other are usually fine — they are one unit and they change together. The problem is a cycle across a boundary that is supposed to contain change (Module Granularity).
Smells this explains
  • shotgun-surgery
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • An architecture test asserting the module graph is acyclic, run in CI. This is the cheapest permanent guard in the whole module (What to Automate Out of Review).
  • A test that imports each module in isolation and asserts it initialises — this is what catches load-order fragility before production does (Validate at Startup, Fail Loudly).
  • After the fix, a unit test for the extracted concept, which should be trivially fast; if it is not, the extraction took too much with it (What a Unit Is).
How this design ages
  • Cycles appear during periods of fast feature work and are never removed by accident. Every one has to be paid for deliberately.
  • They get more expensive to break over time in a specific way: each new member adds edges, and each new dependent inherits the whole cycle.
  • A codebase with an enforced acyclic graph acquires an option it did not have — any subtree can be extracted into a package or a service later without archaeology (Monorepo vs Polyrepo).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat a cycle makes its members one reasoning unit, unloadable in isolation and untestable apart, follows from what a dependency is and holds everywhere.
  • LANGUAGE-SPECIFICThe consequences differ sharply. Java and C# resolve most cycles at class-load time and cycles are common and largely harmless within a package; Python and JavaScript execute module bodies on import, so a cycle produces partially-initialised modules and genuine runtime bugs; Go forbids import cycles outright at the package level, so the language does the enforcement for you and the design pressure shows up as compile errors instead of subtle failures. The reasoning cost is identical in all four; only the failure mode changes.
  • CONTESTEDThe strongest opposing position: cycles between closely-related types are natural — an order references its customer and a customer lists their orders — and contorting a model to satisfy a graph property produces artificial indirection, extra modules and worse names, all to satisfy a tool. That is right about cycles *within* a cohesive module, where the members genuinely are one unit and should probably be one file or one package. The disagreement narrows to cycles that cross a boundary you are relying on to contain change, where the cycle is direct evidence that the boundary does not.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — module resolution, initialisation order and separate compilation decide whether a cycle is a compile error, a runtime hazard or merely a reasoning cost.