How Nx builds the project graph and what a project-level cycle means
Nx models a workspace as a directed graph. Each project, whether an application or a library, is a node. An edge from project A to project B means A depends on B. Nx creates most edges by statically analyzing source files. It finds import and require statements, resolves the specifiers through the workspace's path mappings or package names, and records which project owns the target file. You can add more edges with implicitDependencies in a project's configuration. These cover links that analysis can't see, such as a service that reads another project's generated output.
A project-level cycle means following edges from one project eventually leads back to it: orders depends on billing, and billing depends on orders. This is different from a cycle between files inside a single project. Your bundler or runtime may tolerate an internal file cycle, and the project graph never shows one, because both files belong to the same node. A cross-project cycle is harder to live with. Both projects can only be built, tested, and cached as a unit, so neither can be released or reasoned about independently. Engineers who own shared backend libraries, such as auth, persistence, and messaging, feel this first because their code is imported most widely.
Static analysis has limits. A require with a computed path, a dependency-injection container resolving a provider by string token, or a plugin loaded from config at runtime can couple two projects with no edge in the graph. The reverse also happens: type-only imports usually produce edges even though they disappear from compiled output, so the graph can show a cycle that never exists at runtime.
import / require in source files project config:
| implicitDependencies
v |
+----------------------------------+ |
| static analysis: resolve via | |
| path mappings / package names, | |
| record the owning project | |
+----------------------------------+ |
| |
v v
+--------------------------------------------------------------------------+
| PROJECT GRAPH: node = project, edge A --> B means A depends on B |
| |
| +--------------------+ edge +--------------------+ project cycle: |
| | orders |--------->| billing | build, test and |
| | +----+ +----+ |<---------| | cache as a unit |
| | | a |<-->| b | | edge +--------------------+ |
| | +----+ +----+ | | |
| | file cycle: not | | type-only import: still edge|
| | in project graph | v (may be erased at runtime) |
| +--------------------+ +------------------------+ |
| | shared libs: auth, | |
| | persistence, messaging | |
| +------------------------+ |
+--------------------------------------------------------------------------+
+----------------------------------------------------------------------+
| NOT IN GRAPH, still real coupling: require(computedPath), |
| DI provider resolved by string token, plugin loaded from config |
+----------------------------------------------------------------------+
Nx builds project-graph edges from statically resolved imports plus implicitDependencies, so the orders/billing project cycle appears while a file cycle inside one project and runtime-only coupling do not, and type-only imports can still add edges.
Why project cycles matter: the runtime and build symptoms they cause
That distinction decides how urgently a cycle needs fixing. A project cycle is a statement that two parts of the backend cannot be understood, built or released independently. Nx can lint it, but the useful signal is architectural.
The runtime failures come from module evaluation order. When orders imports billing and billing imports orders, one of them is evaluated first and sees the other half-initialized. In CommonJS output, that shows up as an export that is undefined at load time, often a decorator or a base class. In native ESM, it shows up as a ReferenceError from accessing a binding before initialization. These bugs depend on which entry point loads first, so a test can pass while the service crashes on boot.
The build failures come from the graph itself. A ^build dependency has no valid order inside a cycle. Changing either project marks both as affected. Because each project's cache hash includes its dependencies, an edit to one invalidates the cache for the whole loop. The cycle stops being local.
| Edge kind | Survives compilation | Symptoms | Priority |
|---|---|---|---|
| Runtime import (values, classes, DI tokens) | Yes | Undefined imports, boot-order bugs, no valid build order | Fix now |
Type-only import (import type) |
No | Broader affected set, shared cache invalidation, coupling | Schedule |
Type-only cycles still widen affected runs and cache invalidation. However, they cannot break module loading, so you can add them to the backlog. Fix runtime edges first, starting with the ones on service entry points.
Who should use project-graph cycle detection
This approach is for teams whose backend code already lives in an Nx workspace split into several applications and libraries. The people who get the most out of it are:
- Owners of shared libraries such as auth, persistence, messaging and contracts. Their projects have the most dependents, so a single upward import from them creates cycles across many projects.
- Platform or build engineers who run CI and caching for the workspace. Cycles show up for them as oversized
affectedsets, cache misses and ambiguous task ordering, and the JSON export gives them something they can check automatically. - Tech leads responsible for layering. Tags and
depConstraintsturn an architecture diagram into lint rules, and the project graph is the way to check whether the codebase still matches it. - Engineers debugging boot-order failures, such as an import that is
undefinedonly when the service starts from a particular entry point. Tracing the path between two projects innx graphis often the fastest way to find the loop behind it.
It is of little use for a workspace with one or two projects, where every cycle is a file-level cycle inside one node, or for code that is not organised as Nx projects at all.
When to use it
Use project-graph cycle detection at these points:
- When the symptoms appear. An export is
undefinedat load time, aReferenceErroroccurs during module initialization,nx affectedkeeps including projects that should be unrelated, or a change to one library invalidates the cache for a group of others. - Before splitting or extracting a library. Cycles must be resolved before a project can be built, versioned or deployed on its own, so audit its edges before you start.
- When adopting tags and
depConstraintsin an existing workspace. Run a full audit first so you know how many violations to expect and can plan the staged rollout described below. - Continuously in CI. Once the workspace is clean, run lint with
@nx/enforce-module-boundariesand, if you want a project-level report, the cycle script againstnx graph --file=graph.jsonon every pull request, so new cycles fail before they merge. - During periodic architecture reviews, to check whether the exemption list is shrinking and whether
utilsor the contracts library is starting to attract domain code.
You can skip a dedicated cycle audit when the workspace is small, full builds are already fast, and the only cycles are type-only edges between libraries that one team always changes together. In that case, record the cycle and revisit it when the workspace grows.
Finding the cycle with nx graph, JSON export and enforce-module-boundaries
Run nx graph to open the interactive visualization in a browser. In a large backend workspace the full graph is too dense to read, so pick one suspect project in the sidebar and focus it. That hides everything outside its neighbourhood. Look at both directions. Dependencies show what the project imports. Dependents show who imports it. A cycle exists when a project appears in both sets. To confirm it, use path tracing between the two projects. If a path exists from A to B and another from B back to A, you have a cycle, and the edges along those paths are your candidates.
The UI works for one investigation, but it does not help you audit a whole workspace or run checks in CI. For that, export the graph with nx graph --file=graph.json. The graph.dependencies map lists, for each project, an array of edges with source, target and type. A short depth-first search over that map with an "in progress" marker reports every back edge. Filter out implicit edges if you only care about code imports. Nx computes this file once, so the script runs in milliseconds even with hundreds of projects.
Neither view tells you which file creates an edge. The @nx/enforce-module-boundaries ESLint rule does. When it flags a circular dependency, the message names the project chain and the file chain. The last file in that chain contains the import that closes the loop, and that import is the one to change.
// find-cycles.js: run `nx graph --file=graph.json` first, then `node find-cycles.js graph.json`
const fs = require('fs');
const { graph } = JSON.parse(fs.readFileSync(process.argv[2] || 'graph.json', 'utf8'));
const deps = graph.dependencies; // { projectName: [{ source, target, type }] }
const state = {}; // undefined = unvisited, 1 = in progress, 2 = done
const stack = [];
const cycles = [];
function visit(project) {
state[project] = 1;
stack.push(project);
for (const edge of deps[project] || []) {
if (edge.type === 'implicit') continue; // only care about real code imports
if (!(edge.target in deps)) continue; // skip external npm nodes if present
if (state[edge.target] === 1) {
// Back edge: the target is still on the stack, so the slice from it is a loop
const start = stack.indexOf(edge.target);
cycles.push([...stack.slice(start), edge.target].join(' -> '));
} else if (state[edge.target] === undefined) {
visit(edge.target);
}
}
stack.pop();
state[project] = 2;
}
for (const project of Object.keys(deps)) {
if (state[project] === undefined) visit(project);
}
if (cycles.length) {
console.error(`Found ${cycles.length} cycle(s):`);
cycles.forEach((c) => console.error(' ' + c));
process.exit(1); // fail the CI job
}
console.log('No circular project dependencies.');
A Node script that walks the dependencies map from nx graph --file=graph.json with a depth-first search, prints each project cycle it finds via back edges (ignoring implicit edges), and exits non-zero so CI fails.
Common backend cycle patterns
A cycle that the graph or the lint rule reports in a backend workspace is usually one of four recurring shapes.
Shared DTOs inside a feature lib
orders defines OrderDto, and billing needs it, so billing imports from @acme/orders. Later orders calls billing to compute totals. The graph shows two feature projects pointing at each other. The closing import is usually the type-only one: import { OrderDto } from '@acme/orders' in a billing file. Nx counts type imports as dependencies, so a cycle that exists only through types is still a cycle.
Repository importing service code
orders-data should depend on nothing domain-level. Then someone reuses a validation helper by adding import { validateOrder } from '@acme/orders-service' inside the repository. The graph shows the data layer pointing up at the service layer, which already points down at it. The data layer should sit at the bottom of the graph, so any edge going upward from it is the one that breaks the layering.
Event handlers importing their emitters
notifications handles OrderPlaced. To get the event class, the handler imports from @acme/orders, which emits the event and imports notifications to register handlers. The edge from the handler back to the emitter closes the loop.
The utils lib that absorbs domain logic
utils starts with date and string helpers. Then a formatInvoiceNumber gets added, and it imports from @acme/billing. Because nearly every project depends on utils, this one import can create cycles with many projects at once. In the graph, this shows up as a cluster of cycles that all pass through utils.
// 1. Shared DTO in a feature lib: libs/billing/src/lib/totals.ts
// Type-only, but Nx still records billing -> orders.
import type { OrderDto } from '@acme/orders';
export const total = (o: OrderDto) => o.lines.reduce((s, l) => s + l.amount, 0);
// ...while libs/orders/src/lib/checkout.ts already does:
// import { total } from '@acme/billing'; // orders -> billing
// 2. Repository importing service code: libs/orders-data/src/lib/order.repo.ts
// The data layer points up at the service layer, which already depends on it.
import { validateOrder } from '@acme/orders-service';
export async function save(order: unknown) {
validateOrder(order); // closes orders-data -> orders-service -> orders-data
}
// 3. Handler importing its emitter: libs/notifications/src/lib/order-placed.handler.ts
// The event class lives with the emitter, so the handler reaches back for it.
import { OrderPlaced } from '@acme/orders';
export const onOrderPlaced = (e: OrderPlaced) => console.log('notify', e.orderId);
// ...while libs/orders/src/lib/events.ts registers handlers:
// import { onOrderPlaced } from '@acme/notifications'; // orders -> notifications
// 4. utils absorbing domain logic: libs/utils/src/lib/format.ts
// utils was a leaf; this one edge forms a cycle through every lib that billing
// depends on (directly or indirectly) and that also depends on utils.
import { InvoicePrefix } from '@acme/billing';
export const formatInvoiceNumber = (n: number) =>
`${InvoicePrefix}-${String(n).padStart(6, '0')}`;
Minimal "before" imports for the four common backend cycle shapes, with the edge that closes each loop marked in the comments.
Ways to break a cycle and the trade-offs of each
Each fix removes one edge from the graph. They differ in which edge goes away and what you pay for it later.
| Fix | What it fixes | What it costs | Avoid when |
|---|---|---|---|
| Extract shared contracts into a lower-level lib | Cycles caused only by types, DTOs, enums or constants | The contracts lib attracts unrelated code and becomes a new utils that everything depends on |
The shared code has behavior, not just shapes |
| Invert with an interface or injection token owned by the consumer | A lower layer calling up into a higher one | Indirection: "go to definition" lands on the interface, and debugging means finding where the binding is registered | Only one implementation will ever exist and both libs always change together |
| Replace direct calls with events | Fire-and-forget side effects such as notifications or audit logs | Synchronous coupling is still there but no longer visible. You now own failure handling, retries and ordering | The caller needs a result or transactional consistency |
| Merge the two libs | Two libs that are really one module | Coarser cache granularity. nx affected marks more projects for every change |
The merged lib would be large or owned by separate teams |
Moving a type is the cheapest fix. If orders imports InvoiceStatus from billing only for its shape, move the type into billing-contracts. Both libs then depend downward on it.
Inversion fits cases where billing actually calls orders. In that case, billing declares an OrderLookup interface and a token, and orders provides the implementation at application wiring time. The import now points from orders to billing, and the cycle is gone.
// libs/billing-contracts/src/index.ts
// Fix 1: the shape moves down into a lib with no dependencies of its own.
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'void';
// libs/billing/src/order-lookup.ts
// Fix 2: billing owns the interface it needs. It no longer imports orders.
export interface OrderLookup {
getOrderTotal(orderId: string): Promise<number>;
}
export const ORDER_LOOKUP = Symbol('ORDER_LOOKUP'); // token used by the DI container
// libs/billing/src/invoice.service.ts
import type { InvoiceStatus } from '@acme/billing-contracts';
import type { OrderLookup } from './order-lookup';
export class InvoiceService {
constructor(private readonly orders: OrderLookup) {} // implementation is injected at wiring time
async draftInvoice(orderId: string): Promise<{ total: number; status: InvoiceStatus }> {
return { total: await this.orders.getOrderTotal(orderId), status: 'draft' };
}
}
// libs/orders/src/order-lookup.adapter.ts
// The edge now points orders -> billing, so the cycle is gone.
import type { OrderLookup } from '@acme/billing';
export class OrderLookupAdapter implements OrderLookup {
async getOrderTotal(orderId: string): Promise<number> {
return 4200; // placeholder: read from the orders repository
}
}
// apps/api/src/main.ts
// Only the app knows both sides. Debugging starts here when you need the real binding.
import { InvoiceService } from '@acme/billing';
import { OrderLookupAdapter } from '@acme/orders';
const invoices = new InvoiceService(new OrderLookupAdapter());
Moving InvoiceStatus into billing-contracts and putting an OrderLookup interface in billing so orders depends on billing and not the reverse, with the app wiring the two together.
Preventing regressions with tags and depConstraints
Fixing one cycle does not stop the next one. enforce-module-boundaries already reports circular imports between projects, but by then the dependency is written and merged elsewhere. Tags let you encode the intended layering so the edge that would start a cycle fails lint on its own.
Give each project two tags in its project.json: a type (type:feature, type:data-access, type:util) and a scope (scope:billing, scope:orders, scope:shared). Then add depConstraints to the lint rule. type:feature may depend on type:data-access and type:util. type:data-access may depend only on type:util. type:util may depend only on other utils. Separately, scope:billing may depend on scope:billing and scope:shared. With these rules, an import from a data-access lib up into a feature lib is rejected, and that upward edge is how most backend cycles begin.
In an existing workspace, turning on strict constraints at once can produce hundreds of violations, and teams will disable the rule to unblock merges. Roll out in stages:
- Tag every project first.
- Enforce scope constraints.
- Enforce type constraints.
Record each remaining violation as an explicit exemption, either an inline disable comment or an allow entry. Give each exemption a named owner and an expiry date, and review the list regularly so it shrinks.
The contracts library from the previous section needs its own tag, such as type:contracts. Every project may depend on it, and it may depend only on utils. Assign it a dedicated owner, because it is the one place every scope meets.
Alternatives to Nx project-graph cycle detection
The project graph is not the only way to find or prevent cycles. Each alternative covers a different part of the problem.
| Alternative | What it catches | What it misses | Fits when |
|---|---|---|---|
| File-level import-cycle lint rules or bundler warnings | Cycles between individual modules, including those inside a single project | Says nothing about project boundaries, affected sets or cache invalidation; slower on large codebases because it walks every file |
You care about runtime load-order bugs inside one large project |
| Standalone dependency-graph analysis tools run against source files | Module- or folder-level cycles, often with configurable layering rules | Does not know Nx project ownership, tags or implicitDependencies, so its graph can disagree with what Nx builds and caches |
The code is not in Nx, or you want one tool across repositories with different build systems |
| Package-level checks from the package manager's workspace feature | Cycles between packages declared in each package.json |
Misses imports that bypass declared dependencies, such as path-mapped imports, which is how Nx libraries are usually consumed | Each library is a real package with its own manifest |
| Separate repositories and versioned, published packages | Makes a cycle hard to create, because consumers can only depend on published versions | Adds release overhead and version skew; cycles can reappear as coordinated releases | Teams and release cadences are already independent |
| Code review and written architecture rules | Intent that tools cannot express, such as "billing should not know about notifications" | Does not scale and is not enforced; regressions slip through busy reviews | Very small workspaces, or as a complement to automated checks |
| Merging the projects | Removes the cycle by removing the boundary | Loses cache granularity and independent ownership | The two projects were never meant to be separate |
These options can be combined. A common split is to use a file-level cycle rule inside large projects to catch load-order bugs, and the Nx project graph with tags and depConstraints for boundaries between projects, because only Nx's own graph reflects what it will build, test and cache together.
Verifying the fix and the impact on cost and engineering time
Confirm the edge is gone
Rerun the same check that found the cycle. Export the graph to JSON with nx graph --file=graph.json and run your cycle script against it, or open nx graph and focus on the two projects that used to point at each other. The edge you removed should be gone, and no new path should connect them through a third project. Moving code sometimes just lengthens the loop. Then run lint so @nx/enforce-module-boundaries checks every import against the new tags.
Next, check that the change shows up in behaviour. Touch a file in the former downstream project and run nx affected -t build. The upstream project should no longer be in the affected set. Run a full build and confirm the task order is now a strict sequence instead of whatever order Nx chose to break the loop.
Is it worth it?
Illustrative estimate (assumed numbers, not a benchmark)
- Refactor and rollout: 3 engineer-days = 24 hours (assumed)
- Unnecessary rebuild time per affected run: 6 minutes (assumed)
- Affected runs per week that hit the cycle: 40 (assumed)
- Load-order debugging: 2 hours per month (assumed)
Weekly saving = 40 × 6 / 60 + 2 / 4.3 ≈ 4.5 hours Payback = 24 / 4.5 ≈ 5.3 weeks
Use your own CI history to fill in these inputs. If the payback period is longer than the code is expected to live, or the workspace is small enough that full builds are already fast, record the cycle and leave it in place.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx circular dependency, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.