If you've ever worked with an Nx monorepo (especially in a React front-end), chances are you've hit this wall:
"Why is this import causing a circular dependency⦠and why is my app breaking in the weirdest way possible?"
Recently, I ran into this exact issue while working with a monorepo architecture that looked perfectly reasonable at first glance β until it wasn't. The build failed, the error message was cryptic, and my "perfectly logical" code structure turned out to be a dependency nightmare.
Let me walk you through what happened, why it's dangerous, and how to fix it the right way.
π§© The Scenario: When Good Architecture Goes Circular
Picture this: you're working with an Nx monorepo with the following structure:
apps/
web/
libs/
modules/
product/
price/
shared/
ui/
data/
Your modules:
-
@modules/product- domain logic for products -
@modules/price- pricing logic derived from product data -
@shared/ui- reusable UI components used across the app
Here's where things went sideways:
- The
productmodule needed to access mock data for testing - That mock data lived in the
pricemodule (seemed logical at the time) - The
pricemodule imported UI components fromshared/uito render pricing displays - The
shared/uimodule had aProductCardcomponent that imported types fromproduct
Boom. π₯ Circular dependency detected.
product β price (imports mock data)
β
price β shared/ui (imports UI components)
β
shared/ui β product (imports types and components)
β
β β β (circular!)
Nx caught it. The build failed. And I learned an important lesson about architecture boundaries.
π What Is a Circular Dependency (And Why Should You Care)?
A circular dependency happens when:
- Module A depends on Module B
- Module B depends on Module C
- Module C (directly or indirectly) depends back on Module A
In monorepos, this often happens accidentally because the boundaries between domains, UI, and shared utilities start to blur as the codebase grows.
β οΈ Why This Is Actually Dangerous
Circular dependencies aren't just a linting annoyance. They cause real, production-breaking problems:
β Unpredictable Module Initialization
The JavaScript module system can't determine which module to initialize first. Here's what happens:
// product/index.ts
import { priceMocks } from '@modules/price';
export const productData = {
...priceMocks.products, // priceMocks might be undefined here!
};
// price/index.ts
import { ProductCard } from '@shared/ui';
export const priceMocks = {
products: [...], // This might not be initialized when product imports it
};
Your imports might resolve to undefined, leading to runtime errors that only appear in specific initialization ordersβthe worst kind of bug to debug.
β Broken Tree-Shaking and Bundle Bloat
Bundlers like Webpack and Vite can't properly shake unused code when modules form circular chains. This means:
- Larger bundle sizes
- Slower load times
- Code that should be split across chunks gets bundled together
β Testing Nightmares
When modules depend on each other, mocking becomes impossible:
// Trying to test product module
jest.mock('@modules/price'); // But price depends on shared/ui
jest.mock('@shared/ui'); // But shared/ui depends on product
// π€― Your test setup becomes a house of cards
β Tight Coupling Between Domains
Circular dependencies signal that your domain boundaries are wrong. If product can't exist without price, and price can't exist without product, you don't have separate domainsβyou have one tangled mess.
Worst of all? These issues compound as your codebase grows. What starts as a simple two-module cycle becomes a web of interdependencies that makes refactoring feel like defusing a bomb.
π§ The Root Cause (Hint: It's Architecture, Not Just Imports)
After stepping back and analyzing the problem, I realized the real issue:
Shared UI was doing too much.
When a shared/ui module:
- Knows about domain data (
product,price) - Imports business logic or domain types directly
- Exposes data instead of just UI contracts
β¦it stops being "shared UI" and becomes a dependency trap.
The problem isn't the imports themselvesβit's that module boundaries weren't clearly defined. Each module was reaching across domains because there was no clear contract between layers.
β The Right Way to Fix It: A Complete Refactoring Guide
Let me show you the actual before and after of how I fixed this issue.
π Before: The Circular Dependency
// libs/modules/product/src/index.ts
import { priceMocks } from '@modules/price';
export const useProduct = () => {
return {
...priceMocks.productPrices, // Importing from price module
};
};
// libs/modules/price/src/index.ts
import { ProductCard } from '@shared/ui';
export const priceMocks = {
productPrices: [...]
};
export const PriceDisplay = () => (
<ProductCard /> // Using shared UI
);
// libs/shared/ui/src/components/ProductCard.tsx
import { Product } from '@modules/product'; // Importing from product!
interface ProductCardProps {
product: Product; // Using product domain type
}
The Problem:
-
productβpriceβshared/uiβproduct(circular!) - Shared UI knows about domain types
- Mock data is embedded in feature modules
β After: Clean, One-Way Dependencies
Step 1: Extract Shared Types and Data
// libs/shared/data/src/lib/types/product.types.ts
export interface Product {
id: string;
name: string;
description: string;
}
export interface Price {
productId: string;
amount: number;
currency: string;
}
// libs/shared/data/src/lib/mocks/product.mocks.ts
import { Product } from '../types';
export const productMocks: Product[] = [
{ id: '1', name: 'Widget', description: 'A great widget' }
];
// libs/shared/data/src/lib/mocks/price.mocks.ts
import { Price } from '../types';
export const priceMocks: Price[] = [
{ productId: '1', amount: 29.99, currency: 'USD' }
];
Step 2: Make Shared UI Dumb (On Purpose)
// libs/shared/ui/src/components/ProductCard.tsx
// β
NO imports from feature modules!
interface ProductCardProps {
title: string;
description: string;
price: string;
onAddToCart?: () => void;
}
export const ProductCard: React.FC<ProductCardProps> = ({
title,
description,
price,
onAddToCart
}) => {
return (
<div className="product-card">
<h3>{title}</h3>
<p>{description}</p>
<span className="price">{price}</span>
{onAddToCart && (
<button onClick={onAddToCart}>Add to Cart</button>
)}
</div>
);
};
Key principle: The UI component receives data via props. It knows nothing about where the data comes from. No domain imports. No business logic. No surprises.
Step 3: Feature Modules Orchestrate, UI Presents
// libs/modules/product/src/lib/containers/ProductContainer.tsx
import { ProductCard } from '@shared/ui';
import { productMocks, priceMocks } from '@shared/data';
import { formatPrice } from './utils';
export const ProductContainer: React.FC<{ productId: string }> = ({ productId }) => {
const product = productMocks.find(p => p.id === productId);
const price = priceMocks.find(p => p.productId === productId);
if (!product) return null;
return (
<ProductCard
title={product.name}
description={product.description}
price={formatPrice(price)}
onAddToCart={() => console.log('Added to cart')}
/>
);
};
Step 4: Clear Dependency Flow
@shared/data (types, mocks, utilities)
β
@shared/ui (presentation components)
β
@modules/product, @modules/price (domain logic + orchestration)
β
@apps/web (application)
Each layer only imports from layers below it. Never upward.
π οΈ Enforce It with Nx Module Boundaries
Since you're using Nx, you have a superpower: enforceable module boundaries.
Configure Module Boundaries in Your Nx Workspace
// nx.json or .eslintrc.json
{
"@nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": ["type:ui", "type:data", "type:util"]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": ["type:util", "type:data"]
},
{
"sourceTag": "type:data",
"onlyDependOnLibsWithTags": ["type:util"]
},
{
"sourceTag": "type:util",
"onlyDependOnLibsWithTags": []
}
]
}
]
}
Tag Your Libraries
// libs/modules/product/project.json
{
"tags": ["type:feature", "scope:product"]
}
// libs/shared/ui/project.json
{
"tags": ["type:ui", "scope:shared"]
}
// libs/shared/data/project.json
{
"tags": ["type:data", "scope:shared"]
}
Now Nx will prevent you from creating circular dependencies at compile time. If shared/ui tries to import from modules/product, the linter will immediately fail:
β A project tagged with "type:ui" can only depend on libs tagged with "type:util", "type:data"
π Detection: Catch Circular Dependencies Before They Ship
1. Use Nx's Built-In Dependency Graph
npx nx graph
This opens a visual graph of your workspace. Circular dependencies will be highlighted in red.
2. Add Madge for CI/CD Checks
npm install -D madge
# Add to package.json scripts
"check:circular": "madge --circular --extensions ts,tsx libs/"
Add this to your CI pipeline:
# .github/workflows/ci.yml
- name: Check for circular dependencies
run: npm run check:circular
3. ESLint Plugin for Immediate Feedback
npm install -D eslint-plugin-import
// .eslintrc.json
{
"plugins": ["import"],
"rules": {
"import/no-cycle": ["error", { "maxDepth": 2, "ignoreExternal": true }]
}
}
Your IDE will now warn you as you type when you create a circular import.
π§ The Mental Model Shift
Here's the key mindset change that helped me:
Don't ask: "What feature does this code belong to?"
Ask instead:
- "What does this code depend on?"
- "What should be allowed to depend on this code?"
- "If I delete this module, what breaks?"
Think of your architecture like a building:
- Foundation (shared/data, shared/util): The base that everything rests on
- Floors (shared/ui): Built on the foundation, used by everyone
- Rooms (feature modules): Built on the floors, can't support each other
- Roof (apps): Sits on top, depends on everything below
A room can't hold up another room. That's not how buildings work.
π― Practical Rules to Live By
1οΈβ£ Shared UI Should Be Dumb
A good shared/ui component:
- β Receives data via props
- β Has no idea where data comes from
- β Could be published as an npm package tomorrow
- β Never imports from feature modules
- β Contains no business logic
Think of it like Material-UI or Chakraβit renders what you give it, period.
2οΈβ£ Feature Modules Should Never Import Each Other
If product needs something from price, that "something" belongs in shared/data.
// β Bad
import { calculateDiscount } from '@modules/price';
// β
Good
import { calculateDiscount } from '@shared/data';
3οΈβ£ Types and Interfaces Belong in Shared Layers
If more than one module needs a type, it's shared by definition:
// β Bad - defined in feature module
// libs/modules/product/src/types.ts
export interface Product { ... }
// β
Good - defined in shared layer
// libs/shared/data/src/types/product.types.ts
export interface Product { ... }
4οΈβ£ Mocks Don't Live in Production Code
Move mocks out of src/ folders:
libs/
modules/
product/
src/ β Production code
__mocks__/ β Mocks for testing
__storybook__/ β Storybook-specific mocks
Or better yet, centralize them in shared/data/mocks/.
5οΈβ£ One Direction: Dependencies Flow Downward
Apps βββββββββββββ
β
Feature Modules ββ€
β
Shared UI ββββββββ€
β
Shared Data ββββββ€
β
Shared Utils βββββ
If you ever find yourself making an import that goes upward, stop. That's your architectural red flag.
β οΈ When Circular Dependencies Are (Kinda) OK
Exception: Type-Only Imports
TypeScript's import type doesn't create runtime circular dependencies:
import type { Product } from '@modules/product';
// This is safe because types are erased at compile time
export interface Price {
product: Product;
}
However, I still recommend avoiding this. Even if it's technically safe, it creates mental coupling and makes refactoring harder. Better to keep types in shared layers.
π Real-World Impact: What Changed After the Fix
After refactoring to eliminate circular dependencies in our monorepo, we saw:
- β Build time decreased by 23% (from 45s to 35s)
- β Bundle size reduced by 18% (tree-shaking finally worked)
- β Test setup simplified (no more circular mock hell)
- β Onboarding improved (new devs could understand module boundaries)
- β Zero circular dependency errors for 6 months and counting
The best part? When we added new features, the clear boundaries made it obvious where code should live. No more "where should I put this?" debates.
π¬ Conclusion
Circular dependencies in monorepos aren't just a technical nuisanceβthey're architecture feedback. When you see one, don't just "fix the import." Step back and ask:
"Why do these modules know so much about each other?"
Your codebase is asking for clearer boundaries. Listen to it.
By:
- Extracting shared concerns to dedicated layers
- Making UI components presentation-only
- Enforcing module boundaries with Nx
- Detecting violations early with tooling
β¦you create a monorepo that's not just buildable, but maintainable, scalable, and actually enjoyable to work in.
The next time you're tempted to make that "quick import" that completes the circle, pause. Your future self (and your team) will thank you.
π οΈ Quick Reference: Nx Circular Dependency Fix Checklist
- [ ] Run
nx graphto visualize your dependency graph - [ ] Create
@shared/datalibrary for types and mocks - [ ] Move all shared types from feature modules to
@shared/data - [ ] Refactor
@shared/uicomponents to be prop-based (no domain imports) - [ ] Configure Nx module boundary rules with tags
- [ ] Add
import/no-cycleESLint rule - [ ] Set up
madgein CI pipeline - [ ] Update team documentation with dependency rules
- [ ] Add architectural decision record (ADR) explaining the approach
Have you battled circular dependencies in your Nx monorepo? What strategies worked (or didn't work) for you? I'd love to hear your war stories in the comments! π¬
Top comments (0)