The Acyclic Dependencies Principle: Find the Cycle, Cut One Edge
By Sergey Nosov
16 October 2026
These two ES modules ship an order and tell the customer. The first, orders.mjs, ships the order and asks the second, notifications.mjs, to send the notice:
import { sendShippedNotice } from "./notifications.mjs";
export const ORDER_PREFIX = "ORD-";
export function shipOrder(order) {
order.status = "shipped";
sendShippedNotice(order);
}
The second builds the notice from a prefix that the first defines:
import { ORDER_PREFIX } from "./orders.mjs";
const SUBJECT = `Order ${ORDER_PREFIX}`;
export function sendShippedNotice(order) {
console.log(`${SUBJECT}${order.id} has shipped`);
}
A unit test that imports notifications.mjs works: it prints Order ORD-7 has shipped. The application’s entry point imports orders.mjs instead, and on Node.js 24 it crashes before it ships anything, with ReferenceError: Cannot access 'ORDER_PREFIX' before initialization.
Neither file is wrong on its own; the loop between them is. Import declarations are hoisted, so Node.js runs the modules that orders.mjs imports before any line of orders.mjs, even a constant placed above the import. The notifications module therefore reads the prefix before it is set. The test imports notifications.mjs first, which reverses the order.
The Acyclic Dependencies Principle, which Robert C. Martin named in 1996, rules out that loop. This article argues that a cycle quietly makes its modules behave as one. Every cycle therefore deserves a decision, and the usual decision is to cut the edge that points the wrong way. The JavaScript samples were run on Node.js 24.21 and checked with Madge 8.0, dependency-cruiser 18.6, and ESLint 9 with eslint-plugin-import 2.32.
What the Principle Says
The idea is older than its name. In 1979, David Parnas warned that when programs use one another without restraint, “one may end up with a system in which nothing works until everything works.” His remedy was to restrict that use, so that the graph of the “uses” relation “is loop free.” The programs then fall into levels, and each level, with those below it, can be tested and used before the rest is finished.
Martin named the principle in November 1996, in “Granularity,” his Engineering Notebook column for The C++ Report: “The dependency structure between packages must be a directed acyclic graph (DAG). That is, there must be no cycles in the dependency structure.” He introduced it with a scene many developers know: you get your code working, go home, and find it broken the next morning. “Why doesn’t it work? Because somebody stayed later than you!” He called it “the morning after syndrome.”
His answer was to split the code into packages that teams release to one another, with release numbers. Then “none of the teams are at the mercy of the others.” That arrangement works, he added, only if the dependencies between packages form no cycles. When a cycle appears, he showed, its packages “must always be released at the same time. They have, in effect, become one large package.”
His 2000 paper “Design Principles and Design Patterns” lists six package principles, and this principle leads the three about how packages may depend on one another. In Clean Architecture (2017), packages became components, and the principle opens the chapter on component coupling.
The principle governs packages, not classes. Martin’s package was the unit of release. Today it is an npm package, a .NET project, a Java module, or a folder or namespace that a team treats as a module. Classes inside one package may refer to each other freely, because the package is built and released as a whole. JavaScript and Python add a boundary inside the package: every file is a module that initializes separately, so even two files can break each other at startup.
What a Cycle Costs
Each module in a cycle depends, directly or through the others, on every other one, and every cost follows from that:
- Testing: A test of one module loads the others. If a simple unit test needs “so much of everybody else’s stuff,” Martin wrote, “it is probably because there are cycles in the dependency graph.”
- Change: A change to one module can ripple through all the others, and a build that retests dependents retests the whole loop.
- Understanding: To be sure a change is safe, you have to check every module in the loop.
- Releases: A release of any module must stay compatible with every other module in the loop, and a breaking change forces a coordinated release.
- Startup: Where modules load at run time, one of them has to run before a module it imports has finished, and the entry point decides which.
The last cost deserves a closer look, because it turns a design problem into a bug. The opening sample crashed because notifications.mjs read the prefix at the top level. Had it read the prefix only inside its function, both entry points would have worked. ES modules share live bindings, and in this sample no notice goes out until both modules have run. That is how a cycle survives code review: nothing breaks until someone moves one line.
Other module systems fail differently. Rewritten with CommonJS require, the same two modules do not crash. When a cycle closes, the Node.js documentation explains, require returns “an unfinished copy” of the other module’s exports. The notifications module reads the prefix before it is set, so the application prints Order undefined42 has shipped. Node.js adds a warning about “accessing non-existent property 'ORDER_PREFIX' of module exports inside circular dependency,” and the program carries on.
Put into a Python package, the pair stops at from shop.orders import ORDER_PREFIX, even if the prefix is read only inside its function: ImportError: cannot import name 'ORDER_PREFIX' from partially initialized module 'shop.orders' (most likely due to a circular import). Python 3.12, 3.13, and 3.14 behave alike.
Go’s designers met the same costs at Google’s scale and made cycles illegal. In an article based on his SPLASH 2012 keynote, Rob Pike explained the decision:
Although they are occasionally useful, circular imports introduce significant problems at scale. They require the compiler to deal with larger sets of source files all at once, which slows down incremental builds. More important, when allowed, in our experience such imports end up entangling huge swaths of the source tree into large subpieces that are difficult to manage independently, bloating binaries and complicating initialization, testing, refactoring, releasing, and other tasks of software development.
He granted that the rule “causes occasional annoyance but keeps the tree clean, forcing a clear demarcation between packages.”
How Cycles Creep In
Cycles rarely appear by design. They accumulate from small decisions, each reasonable on the day it was made:
- The convenience import: One module needs a constant or a type that happens to live in another, as in the opening sample.
- The growing utility module: A shared helper absorbs a little domain logic, then imports a domain module to do it.
- The callback across a layer: A lower layer needs to notify an upper one, so it imports the upper layer to call it.
- The two-way conversation: A parent and a child component really do need to talk both ways, so each imports the other.
- The missing abstraction: No interface or intermediate layer exists, so modules reach into each other directly.
Martin’s 2000 paper tells such a story. An engineer on the communication-error package wants to show a message, so the package calls a GUI object. That call closes a loop: the error package now depends on the GUI and, through it, on most of the system. The protocol team, which used to build and test with the error package alone, must now build with six packages. Martin concluded that someone “needs to be watching the package dependency structure with regularity, and breaking cycles wherever they appear.”
Adding a quick import is always easier in the moment than designing an interface, and no reviewer sees the whole graph in one diff. That is why the watching belongs to the build.
Where the Toolchain Already Says No
Several toolchains reject a cycle at the boundary they manage:
- Go: The language specification makes it “illegal for a package to import itself, directly or indirectly,” and the
gocommand stops with “import cycle not allowed.” - .NET: The
dotnet add referencecommand accepts two projects that reference each other, but the build fails with error MSB4006: “There is a circular dependency in the target dependency graph.” The message names an internal MSBuild target rather than your projects. - Java: The compiler rejects modules whose
requiresdirectives inmodule-info.javaform a loop; its error reads “cyclic dependence involving” and names a module. Maven refuses to build the modules of a multi-module project that depend on each other: “The projects in the reactor contain a cyclic reference.” - TypeScript: In build mode,
tscrefuses projects whose references form a loop: “Project references may not form a circular graph.” - Rust: Cargo refuses two packages that depend on each other: “cyclic package dependency,” it reports, and names the packages in the loop. It does accept a loop through a dev-dependency, which ordinary builds skip.
JavaScript and Python have no such check, and TypeScript has none inside a project. Their module loaders accept the cycle and let the load order decide.
Notice where each check stops. Go checks every package; the others stop at the coarse units named above. Inside one .NET project, namespaces can depend on each other in a loop, and so can the packages inside one Java module. A Rust crate whose two modules import each other compiles and runs without a warning. The toolchain guards the coarse boundary; inside it, cycles can grow unchecked.
Make the Build Find the Rest
Finding cycles is a solved graph problem. A build system orders its work by topological sort, which is possible exactly when the graph has no cycle. Robert Tarjan’s 1972 algorithm finds the strongly connected components: the largest groups of modules in which every module can reach every other. Any group of two or more modules is a tangle of one or more cycles. The combinatorics article on this site shows a short depth-first search that throws on the first cycle it meets.
You do not need to write one. Each stack has tools that read the imports and report the cycles:
- JavaScript and TypeScript: Madge and dependency-cruiser check the import graph from the command line,
requirecalls included. In the editor, lint rules report cycles:import/no-cyclein eslint-plugin-import (ESLint 9 and earlier) and Oxlint,import-x/no-cyclein the eslint-plugin-import-x fork (also ESLint 10), andnoImportCyclesin Biome. On a CommonJS version of the cycle, only Biome’s rule reported it. Biome and eslint-plugin-import warn that the rule is costly to run. - Java: ArchUnit checks architecture rules in ordinary unit tests, and one of its rules requires groups of packages to be free of cycles.
- .NET: ArchUnitNET brings the same rule to .NET. NDepend, a commercial analyzer, has a rule that lists the namespace dependency cycles inside an assembly. Visual Studio Enterprise can generate code maps, which include a circular references analyzer.
- Python: Pylint’s
cyclic-importcheck reports cycles between modules. The acyclic siblings contract of import-linter, added in November 2025, finds cycles between the subpackages of a package even when the modules themselves form none. - Several languages: SonarQube’s architecture analysis reports “tangles” in Java, C#, JavaScript, TypeScript, and Python code. SonarSource defines a tangle as “a set of classes or files that depend on each other in a cycle.” In October 2026, its documentation lists the analysis for the paid Team and Enterprise plans of SonarQube Cloud and for the commercial editions of SonarQube Server.
Turn the Detector into a Gate
Run the check on every pull request, so that a new cycle fails the build before it merges. Madge finds the opening sample’s cycle from the entry point:
$ npx madge --circular main.mjs
Processed 3 files (318ms)
✖ Found 1 circular dependency!
1) orders.mjs > notifications.mjs
It exits with code 1 when it finds a cycle, so one line in a CI script turns it into a gate. In CI, point it at the source folder, not one entry point, so that it sees every module, and pass --extensions for files other than .js. In dependency-cruiser, the same check is one rule in a configuration file, plus an option that keeps it out of installed packages:
module.exports = {
forbidden: [
{
name: "no-circular",
severity: "error",
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: {
path: "node_modules",
},
},
};
Save it as .dependency-cruiser.cjs and run npx depcruise src, which exits with the number of errors it found. Install dependency-cruiser in the project first, with npm install --save-dev dependency-cruiser. Otherwise, npx fetches a placeholder package named depcruise, and in CI it does so without asking. In October 2026, that placeholder prints a message and exits with code 0, so the gate passes without checking anything. The configuration that depcruise --init writes reports cycles as warnings, which do not fail the build.
ArchUnit and ArchUnitNET express the check as a test. This one uses xUnit and ArchUnitNET 0.13.4 on .NET 10, and it fails when namespaces under Shop depend on one another in a loop:
using ArchUnitNET.Domain;
using ArchUnitNET.Fluent.Slices;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnit;
using Shop.Orders;
public class CycleTests
{
static readonly Architecture Arch =
new ArchLoader()
.LoadAssemblies(
typeof(OrderService).Assembly)
.Build();
[Fact]
public void ShopNamespacesAreFreeOfCycles() =>
SliceRuleDefinition.Slices()
.Matching("Shop.(*)")
.Should()
.BeFreeOfCycles()
.Check(Arch);
}
With a cycle in place, the test fails and lists each step of the loop with the types behind it, such as Shop.Notifications.Notifier -> Shop.Orders.Order. Each namespace under Shop, nested ones included, is a slice of its own. In Java, the ArchUnit rule looks almost the same, slices(), but there each slice is a package directly under com.myapp with its subpackages.
Record the Cycles You Already Have
On a large codebase that already has cycles, failing every build on day one is a good way to get the check switched off. Record the cycles you have, fail only on new ones, and shrink the record over time.
In dependency-cruiser, the --baseline option writes the current violations to a known-violations file, and later runs with --ignore-known skip them. With --baseline-mode shrink-only, an update can remove entries from the file but never add them.
In Java, ArchUnit’s FreezingArchRule does the same for any rule. Once you allow it to create its store, it records the existing violations, and later runs report only new ones. A cycle’s record lists the dependencies behind it, so a new dependency inside an old cycle fails too. Commit the store, keep store creation off in CI, and raise cycles before you freeze a large tangle: the search stops after 100 cycles by default.
ArchUnitNET 0.13.4 has FreezingArchRule too, but a run that finds no store creates one and passes, even in CI; no setting turns that off. Its record also names only the namespaces in a cycle, so a new dependency inside an old cycle passes.
Check Folders and TypeScript Imports
In a JavaScript codebase, a package is often a folder, and two folders can depend on each other even when no two files do. Suppose orders/ship.mjs imports notifications/send.mjs, which imports orders/prefix.mjs. No file is part of a cycle, so Madge reports none, and neither does the dependency-cruiser rule above. The same rule with scope: "folder" added reports notifications → orders → notifications.
TypeScript adds two wrinkles. With path aliases, give Madge and dependency-cruiser the tsconfig, either with --ts-config or in their configuration files. Otherwise they skip the aliased imports and miss any cycle that runs through them.
The other wrinkle is the type-only import, such as import type { Order } from "./orders.ts". Compilation erases it, so a cycle through one cannot fail at load time. Madge counts these imports unless you set skipTypeImports; dependency-cruiser counts them only with tsPreCompilationDeps, which depcruise --init turns on for a TypeScript project. The lint rules ignore them, though Biome’s and Oxlint’s can be told otherwise. I would count them: such a cycle cannot crash, but it still ties the two modules’ contracts together.
How to Break a Cycle
A cycle of two modules has two edges. Remove either one, and the cycle is gone. A larger tangle may need several cuts, but each cut asks the same question: which import points the wrong way? Martin named two ways to remove one in 1996, extraction and inversion, and an event is a looser kind of inversion:
Extract the Shared Part
In the opening sample, the edge from notifications.mjs back to orders.mjs exists for one constant. The prefix is not part of shipping; it is a convention for order numbers, and any module may need it. Move it into a module of its own:
export const ORDER_PREFIX = "ORD-";
Point the notifications module at the new module:
import { ORDER_PREFIX } from "./order-format.mjs";
const SUBJECT = `Order ${ORDER_PREFIX}`;
export function sendShippedNotice(order) {
console.log(`${SUBJECT}${order.id} has shipped`);
}
And let orders.mjs re-export the prefix, so that code that imports it from there keeps working:
import { sendShippedNotice } from "./notifications.mjs";
// Re-exported for old callers
export { ORDER_PREFIX } from "./order-format.mjs";
export function shipOrder(order) {
order.status = "shipped";
sendShippedNotice(order);
}
Both modules now depend on order-format.mjs, and nothing points back at orders.mjs. This is one of Martin’s two mechanisms: “Create a new package that both MyDialogs and MyApplication depend upon. Move the class(es) that they both depend upon into that new package.” Sometimes the shared piece belongs in an existing module instead. In a codebase organized by folders, keep the new module outside both folders: inside the orders folder, it would leave the folder cycle in place.
Invert the Dependency
Sometimes the wrong edge is the other one. Shipping is the policy here; how the customer hears about it is a detail. Martin’s other mechanism removes that import: the module that makes the call declares the interface it needs, and the other module implements it. In 2000 he called this placement a pattern: “Interfaces are very often included in the package that uses them, rather than in the package that implements them.” Here the orders module states what it needs:
export const ORDER_PREFIX = "ORD-";
// Shipping needs a notifier with
// orderShipped(order). This module
// owns that contract.
export function createShipping(notifier) {
return {
shipOrder(order) {
order.status = "shipped";
notifier.orderShipped(order);
},
};
}
The notifications module implements it:
import { ORDER_PREFIX } from "./orders.mjs";
const SUBJECT = `Order ${ORDER_PREFIX}`;
export const emailNotifier = {
orderShipped(order) {
console.log(`${SUBJECT}${order.id} has shipped`);
},
};
The entry point, which already knows every module, connects the two:
import { createShipping } from "./orders.mjs";
import { emailNotifier } from "./notifications.mjs";
// The entry point wires them
const shipping = createShipping(emailNotifier);
// Prints: Order ORD-42 has shipped
shipping.shipOrder({ id: 42 });
Now orders.mjs imports nothing. The notifications module still imports it for the prefix, and that edge points the right way. In TypeScript, the contract would be an interface exported from the orders module, and the notifications module would import that too. A callback is the smallest version of the same move: pass a function instead of an object. The article on SOLID covers the Dependency Inversion Principle itself.
Publish an Event
When several modules may react to a shipment, and shipping should not know which, let the orders module announce it:
import { EventEmitter } from "node:events";
export const ORDER_PREFIX = "ORD-";
export const orderEvents = new EventEmitter();
export function shipOrder(order) {
order.status = "shipped";
orderEvents.emit("shipped", order);
}
The notifications module subscribes:
import { ORDER_PREFIX } from "./orders.mjs";
const SUBJECT = `Order ${ORDER_PREFIX}`;
export function subscribeNotifications(events) {
events.on("shipped", (order) => {
console.log(`${SUBJECT}${order.id} has shipped`);
});
}
The entry point wires them:
import { orderEvents, shipOrder } from "./orders.mjs";
import { subscribeNotifications } from "./notifications.mjs";
subscribeNotifications(orderEvents);
// Prints: Order ORD-42 has shipped
shipOrder({ id: 42 });
Apart from node:events, the imports are the same as in the inverted version. Here too, no import shows the run-time call from orders to notifications, so no import-graph check can see it.
The difference shows when the wiring goes missing. Delete the call to subscribeNotifications, and the program ships the order, prints nothing, and exits normally. Leave the notifier out of the inverted version, and the first shipment throws a TypeError. An event makes the listener optional, which is both its purpose and its risk.
Optional does not mean independent. In Node.js, the EventEmitter “calls all listeners synchronously in the order in which they were registered.” A listener that throws synchronously therefore makes shipOrder throw, and the listeners after it never run. An async listener’s error does not reach shipOrder; it surfaces later as an unhandled rejection, which by default ends the process.
Events can also loop. Suppose orders publishes an event, notifications reacts by publishing another, and orders reacts to that one. The cycle has moved from the imports to the message flow, where it is harder to see.
Or Admit They Are One Module
Sometimes two modules really are one. If they always change together and always ship together, an interface between them is ceremony, and the cycle is telling the truth. Merge them. Martin’s Common Closure Principle points the same way: “Classes that change together, belong together.”
Which Cycles to Fix First
Not all cycles are created equal. When a detector reports dozens of cycles in an older codebase, triage them by severity:
- Across teams or deployable units: Fix these first. They tie releases together and force teams to coordinate their changes.
- Across packages inside one service: These slow builds and make tests harder to set up. Fix them next.
- Between closely related classes or files inside one package: If the package is small and cohesive, these may be tolerable. In JavaScript and Python, import the whole module rather than individual names, and read its exports only inside functions that run after both files have loaded. In CommonJS, also add to
exportsinstead of replacingmodule.exports.
Within a group, start with small cycles in code that changes often: they cause the most day-to-day friction and are the cheapest to break. A cycle among large, central packages can take a long, risky refactoring. Plan that work deliberately instead of slipping it into a sprint as a side task. Keep the CI gate in place, so that the code does not tangle again while you work.
Scale changes the method, not the principle. A reviewer may hold a small system’s graph in mind, but a detector costs one line of CI. In a large system, cycles multiply coordination costs across teams and can span repositories and services. Checks become essential, but each sees only the code it is given; a monorepo makes the whole graph visible to one tool. Between services, a cycle is one way to end up with what is often called a distributed monolith: services that look separate but cannot be deployed apart.
The principle is a guide, not a law. The goal is a codebase that teams can build, test, and change in parts, not purity for its own sake. The principle’s entry in my Software Development Principles series lists the signs that a codebase has drifted. They range from small changes that force full rebuilds to version numbers bumped in lockstep across packages.
A Checklist for Dependency Cycles
- Does CI check the graph? Run the detector for your stack on every pull request, and fail the build on a new cycle.
- Does the check reach inside projects? Except in Go, the toolchain checks only projects, modules, or Cargo packages, if anything. Namespaces, folders, and files need a tool.
- Is there a record of old cycles? Fail on new ones, and make the record shrink.
- Which edge points the wrong way? Extract a shared constant or type. Invert when a policy calls a detail or a lower layer calls back up. Publish an event when the set of listeners is unknown.
- Did an event hide a dependency? Wire subscriptions in one place, and test that they are wired.
- Do the two modules always change together? Consider merging them.
- Does any test start the real application? A smoke test that runs each real entry point and checks its output catches load-order failures that unit tests miss.
Further Reading
- Granularity (Robert C. Martin, The C++ Report, November 1996)
- Design Principles and Design Patterns (Robert C. Martin, 2000)
- Designing Software for Ease of Extension and Contraction (David L. Parnas, IEEE Transactions on Software Engineering, March 1979)
- Go at Google: Language Design in the Service of Software Engineering (Rob Pike, SPLASH 2012, 25 October 2012)
- Modules: CommonJS Modules, Cycles (Node.js documentation)