aph analyzer to visualize dependencies and locate the closure point. Once identified, trace the initialization sequence to determine which module requires the other to be fully constructed before it can execute.
Step 2: Replace Barrel Re-exports with Direct Paths
Barrel files (index.ts) create dense, highly connected graphs. When a utility imports from a barrel that re-exports the utility itself, a cycle forms. Switching to direct imports breaks the loop without changing functionality.
Before (cyclic through barrel):
// billing/utils/invoice.ts
import { PaymentProcessor } from '../billing'; // imports barrel
// billing/index.ts
export { PaymentProcessor } from './payment.processor';
export { generateInvoice } from './utils/invoice';
After (direct import, cycle broken):
// billing/utils/invoice.ts
import { PaymentProcessor } from '../payment.processor'; // direct path
// billing/index.ts remains unchanged, but utilities no longer depend on it
Why this works: Barrels act as central hubs that connect every file in a directory. By importing directly from the source file, you remove the hub from the evaluation path, breaking the loop while preserving the same runtime behavior.
Shared type modules often become cycle anchors when they import value exports from services. Create a dedicated contract file that contains only interfaces, type aliases, and enums. This file must never import from application code.
// contracts/billing.types.ts
export interface InvoicePayload {
amount: number;
currency: string;
recipientId: string;
}
export type BillingStatus = 'pending' | 'processed' | 'failed';
Services and utilities now import from this contract layer instead of each other:
// billing/payment.processor.ts
import type { InvoicePayload } from '../../contracts/billing.types';
export class PaymentProcessor {
async process(payload: InvoicePayload): Promise<void> { /* ... */ }
}
Why this works: TypeScript erases import type at compile time, but the module graph remains intact at runtime. By isolating contracts in a file with zero internal imports, you guarantee that type sharing never creates a runtime edge. This prevents the silent transition from type-only cycles to value cycles when someone later adds a concrete export.
Step 4: Apply Dependency Inversion for Cross-Domain Calls
When two features need to communicate, direct imports create architectural cycles. Instead, define the required interface in the consuming domain and inject the implementation. This follows the Dependency Inversion Principle and ensures modules only depend on abstractions.
// orders/domain/order.service.ts
import type { CustomerLookup } from './customer.contract';
export class OrderService {
constructor(private readonly customerResolver: CustomerLookup) {}
async fulfill(orderId: string): Promise<void> {
const customer = await this.customerResolver.findById(orderId);
// ...
}
}
// customers/domain/customer.contract.ts
export interface CustomerLookup {
findById(id: string): Promise<{ name: string; email: string }>;
}
// customers/infrastructure/customer.adapter.ts
import type { CustomerLookup } from './domain/customer.contract';
import { CustomerRepository } from './customer.repository';
export class CustomerAdapter implements CustomerLookup {
async findById(id: string) {
const record = await CustomerRepository.fetch(id);
return { name: record.fullName, email: record.emailAddress };
}
}
Why this works: Direct cross-feature imports create bidirectional dependencies that lock domains together. By defining the contract in the consumer and injecting the provider, you invert the dependency direction. The consumer no longer imports from the provider, eliminating the cycle while maintaining loose coupling.
Pitfall Guide
-
Assuming import type Breaks Cycles
Explanation: TypeScript erases type-only imports during compilation, but the module graph remains intact at runtime. If a file later gains a value export, the type-only cycle instantly becomes a value cycle.
Fix: Treat type imports as potential runtime edges. Enforce a strict separation between type-only modules and value-exporting modules.
-
Relying on Barrel Files for Developer Experience
Explanation: Barrels simplify import paths but create highly connected graphs where any utility can accidentally import the barrel that re-exports it. This is the most common source of accidental cycles.
Fix: Disable barrel imports in linting rules. Configure path aliases to point directly to feature entry points or use direct relative imports.
-
Ignoring Linter Depth Limits
Explanation: Many cycle detectors use depth-first search with a maxDepth parameter. Cycles longer than the limit are silently ignored, giving false confidence in large codebases.
Fix: Set maxDepth to Infinity or a value exceeding your codebase's longest plausible path. Validate detector output against a known cycle.
-
Treating Bundler Reordering as a Solution
Explanation: Rollup and esbuild linearize modules and wrap them in IIFEs to resolve cycles. This masks the problem rather than fixing it. The bundle may work in development but fail in production or when chunk splitting changes evaluation order.
Fix: Never rely on bundler behavior to resolve cycles. Treat bundler success as a false positive and refactor the graph.
-
Mixing Value and Type Exports in Shared Modules
Explanation: A file that exports both interfaces and concrete classes becomes a cycle anchor. Any change to the value exports can trigger initialization failures in dependent modules.
Fix: Split modules into *.types.ts (zero internal imports) and *.impl.ts (concrete logic). Enforce this split with architectural linting rules.
-
Overlooking Test Graph Inflation
Explanation: Circular dependencies prevent true test isolation. Loading a single utility pulls in its entire dependency tree, including ORMs, HTTP clients, and auth layers. Test suites slow down exponentially as features grow.
Fix: Mock external dependencies at the boundary. Use dependency injection to swap implementations during testing. Verify test isolation by measuring import graph size per test file.
-
Using console.log to Debug Timing Issues
Explanation: Adding logging shifts evaluation timing just enough to alter module initialization order. The bug appears or disappears based on load sequence, making it nearly impossible to reproduce consistently.
Fix: Remove all timing-dependent debugging. Use deterministic graph analysis tools and enforce strict initialization boundaries. Log only after module evaluation completes.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small codebase (<500 files) | Direct imports + ESLint cycle rule | Low overhead, immediate feedback, minimal refactoring | Low: Developer time for path updates |
| Medium codebase (500–2000 files) | Contract layer + barrel removal | Prevents type/value mixing, scales with team growth | Medium: Initial refactoring sprint |
| Large codebase (>2000 files) | Dependency inversion + architectural linting | Enforces domain boundaries, prevents cross-feature coupling | High: Requires phased migration and team alignment |
| Legacy CommonJS project | Gradual ESM migration + madge audit | CJS partial exports mask cycles; ESM exposes them clearly | Medium: Migration effort + runtime testing |
| Micro-frontend architecture | Shared contracts + runtime dependency injection | Prevents build-time coupling, enables independent deployments | High: Infrastructure setup + contract versioning |
Configuration Template
// eslint.config.mjs
import importNext from 'eslint-plugin-import-next';
import tseslint from 'typescript-eslint';
export default tseslint.config(
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
'import-next': importNext,
},
rules: {
'import-next/no-cycle': ['error', {
maxDepth: Infinity,
ignoreExternal: true,
allowTypeImports: false,
}],
},
}
);
// package.json scripts
{
"scripts": {
"audit:cycles": "npx madge --circular --extensions ts,tsx src/",
"lint:graph": "eslint --max-warnings=0 src/",
"precommit": "npm run lint:graph"
}
}
Quick Start Guide
- Install detection tools: Run
npm install --save-dev eslint-plugin-import-next madge to add graph analysis and linting to your project.
- Run baseline audit: Execute
npx madge --circular --extensions ts src/ to map existing cycles and prioritize refactoring targets.
- Configure linting: Add the ESLint configuration template above to your project root. Commit the change to establish a clean baseline.
- Break the first cycle: Identify the highest-impact cycle (usually a barrel or shared type file). Replace barrel imports with direct paths and extract types to a zero-import contract module.
- Verify in CI: Push the changes and confirm that the linting gate passes. Monitor bundle size and test execution time to validate the architectural improvement.