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:

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:

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:

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:

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().matching("com.myapp.(*)..").should().beFreeOfCycles(), 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.maxNumberToDetect before you freeze a large tangle: the search stops after 100 cycles by default.

ArchUnitNET 0.13.4 has FreezingArchRule.Freeze 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:

An arrow points to what a module imports. The cycle Extract the shared part Invert the dependency, or publish an event orders notifications orders notifications order-format main notifications orders

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:

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

Further Reading