DEV Community

Cover image for Circular Dependencies in Monorepos: The Silent Architecture Killer
Yanka Santos(She/her)
Yanka Santos(She/her)

Posted on

Circular Dependencies in Monorepos: The Silent Architecture Killer

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/
Enter fullscreen mode Exit fullscreen mode

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:

  1. The product module needed to access mock data for testing
  2. That mock data lived in the price module (seemed logical at the time)
  3. The price module imported UI components from shared/ui to render pricing displays
  4. The shared/ui module had a ProductCard component that imported types from product

Boom. πŸ’₯ Circular dependency detected.

product β†’ price (imports mock data)
    ↓
price β†’ shared/ui (imports UI components)
    ↓
shared/ui β†’ product (imports types and components)
    ↓
    ← β†’ ← (circular!)
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

❌ 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
}
Enter fullscreen mode Exit fullscreen mode

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' }
];
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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')}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

Step 4: Clear Dependency Flow

@shared/data (types, mocks, utilities)
    ↓
@shared/ui (presentation components)
    ↓
@modules/product, @modules/price (domain logic + orchestration)
    ↓
@apps/web (application)
Enter fullscreen mode Exit fullscreen mode

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": []
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

πŸ” Detection: Catch Circular Dependencies Before They Ship

1. Use Nx's Built-In Dependency Graph

npx nx graph
Enter fullscreen mode Exit fullscreen mode

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/"
Enter fullscreen mode Exit fullscreen mode

Add this to your CI pipeline:

# .github/workflows/ci.yml
- name: Check for circular dependencies
  run: npm run check:circular
Enter fullscreen mode Exit fullscreen mode

3. ESLint Plugin for Immediate Feedback

npm install -D eslint-plugin-import
Enter fullscreen mode Exit fullscreen mode
// .eslintrc.json
{
  "plugins": ["import"],
  "rules": {
    "import/no-cycle": ["error", { "maxDepth": 2, "ignoreExternal": true }]
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or better yet, centralize them in shared/data/mocks/.

5️⃣ One Direction: Dependencies Flow Downward

Apps ────────────┐
                 ↓
Feature Modules ──
                 ↓
Shared UI ────────
                 ↓
Shared Data ──────
                 ↓
Shared Utils β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 graph to visualize your dependency graph
  • [ ] Create @shared/data library for types and mocks
  • [ ] Move all shared types from feature modules to @shared/data
  • [ ] Refactor @shared/ui components to be prop-based (no domain imports)
  • [ ] Configure Nx module boundary rules with tags
  • [ ] Add import/no-cycle ESLint rule
  • [ ] Set up madge in 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! πŸ’¬

Nx #Monorepo #React #SoftwareArchitecture #CleanCode #Frontend #TypeScript #WebDevelopment #SoftwareEngineering

Top comments (0)