Skip to content
Arxo Arxo

Breaking Cycles

Circular dependencies (cycles) are one of the most common architectural issues. This guide shows you how to identify and fix them.

A cycle occurs when modules depend on each other in a circular way:

A imports B → B imports C → C imports A

This creates a Strongly Connected Component (SCC) where all modules are mutually dependent.

  1. Harder to understand - Cannot reason about modules in isolation
  2. Harder to test - Cannot test modules independently
  3. Blocks tree-shaking - Bundlers cannot eliminate dead code
  4. Prevents optimization - Compilers cannot optimize
  5. Increases coupling - Changes propagate unpredictably

Goal: Zero cycles. Every module should have clear dependencies flowing in one direction.


Terminal window
arxo analyze --metric scc

Output:

📊 SCC Metrics:
scc.component_count: 75 (183 total nodes)
scc.max_cycle_size: 8 ⚠️ Large cycle!
scc.total_nodes_in_cycles: 24

Interpretation:

  • scc.component_count = 75 means there are 75 SCCs (should be 183 for no cycles)
  • scc.max_cycle_size = 8 means the largest cycle has 8 modules
  • 24 modules are stuck in cycles
Terminal window
arxo analyze --format json --output report.json
# Extract SCC details
jq '.results[] | select(.id=="scc") | .evidence' report.json

Example Evidence:

{
"evidence": [
{
"type": "cycle",
"severity": "high",
"nodes": [
"src/services/auth.ts",
"src/services/user.ts",
"src/services/session.ts"
],
"message": "Circular dependency involving 3 modules"
}
]
}

Problem:

services/auth.ts
import { User } from './user';
export class Auth {
validateUser(user: User) { }
}
// services/user.ts
import { Auth } from './auth';
export class User {
auth: Auth;
}

Solution 1: Extract Interface

interfaces/IAuth.ts
export interface IAuth {
validateUser(user: User): boolean;
}
// services/user.ts
import { IAuth } from '../interfaces/IAuth';
export class User {
auth: IAuth; // Depend on interface
}
// services/auth.ts
import { User } from './user';
import { IAuth } from '../interfaces/IAuth';
export class Auth implements IAuth {
validateUser(user: User) { }
}

Solution 2: Dependency Injection

services/user.ts
export class User {
constructor(private auth: any) { } // Inject at runtime
}
// services/auth.ts
import { User } from './user';
export class Auth {
validateUser(user: User) { }
}
// index.ts
const auth = new Auth();
const user = new User(auth);

Problem:

models/User.ts
import { Post } from './Post';
export interface User {
posts: Post[];
}
// models/Post.ts
import { User } from './User';
export interface Post {
author: User;
}

Solution: Extract Type Definitions

types/User.ts
import { Post } from './Post';
export interface User {
posts: Post[];
}
// types/Post.ts
import { User } from './User';
export interface Post {
author: User;
}
// models/User.ts
import { User } from '../types/User';
export class UserModel implements User {
posts = [];
}
// models/Post.ts
import { Post } from '../types/Post';
export class PostModel implements Post {
author!: User;
}

Types can have cycles (they’re compile-time only), but implementations should not.


Problem:

utils/string.ts
import { formatNumber } from './number';
export function formatString(s: string, n: number) {
return `${s}: ${formatNumber(n)}`;
}
// utils/number.ts
import { formatString } from './string';
export function formatNumber(n: number) {
return formatString('Number', n); // Cycle!
}

Solution: Extract Shared Utility

utils/string.ts
export function formatString(s: string, n: string) {
return `${s}: ${n}`;
}
// utils/number.ts
export function formatNumber(n: number) {
return n.toFixed(2);
}
// utils/composite.ts
import { formatString } from './string';
import { formatNumber } from './number';
export function formatNumberWithLabel(label: string, n: number) {
return formatString(label, formatNumber(n));
}

Problem:

components/Parent.tsx
import { Child } from './Child';
export function Parent() {
return <Child />;
}
// components/Child.tsx
import { Parent } from './Parent';
export function Child() {
// Somehow uses Parent
}

Solution: Inversion of Control

components/Child.tsx
export function Child({ onAction }: { onAction: () => void }) {
return <button onClick={onAction}>Click</button>;
}
// components/Parent.tsx
import { Child } from './Child';
export function Parent() {
const handleAction = () => { /* ... */ };
return <Child onAction={handleAction} />;
}

Terminal window
# Get JSON report
arxo analyze --format json --output report.json
# Find largest cycle
jq '.results[] | select(.id=="scc") | .evidence[] | select(.severity=="high")' report.json

Output:

{
"nodes": ["A.ts", "B.ts", "C.ts"],
"size": 3
}

Create a diagram of the cycle:

A.ts → B.ts
↑ ↓
└── C.ts

Ask:

  • Which dependency is least essential?
  • Which module could work without the other?
  • Which import is only for types?

Apply one of these strategies:

Strategy 1: Extract Shared Code

  • Move common code to a new module both can import

Strategy 2: Dependency Injection

  • Pass dependencies at runtime instead of importing

Strategy 3: Event System

  • Use events/callbacks instead of direct calls

Strategy 4: Interface Segregation

  • Depend on interfaces, not implementations
Terminal window
arxo analyze --metric scc
# Should show:
# scc.max_cycle_size: 0 ✓

Add to .arxo.yaml:

policy:
invariants:
- metric: scc.max_cycle_size
op: "=="
value: 0
message: "No circular dependencies allowed"

Run in CI:

Terminal window
arxo analyze --preset ci --fail-fast
.git/hooks/pre-commit
#!/bin/bash
arxo analyze --quick --metric scc --quiet
if [ $? -ne 0 ]; then
echo "❌ Circular dependency detected!"
exit 1
fi

VSCode Extension:

MCP with AI Assistant:

  • Install the MCP server
  • Ask your AI: “Check for circular dependencies”
  • Uses check_cycles for instant feedback (see MCP Workflows)

Example conversation:

You: "Before I commit, check for cycles"
AI: [Runs check_cycles] "✓ No circular dependencies detected"
  • Does this PR introduce new imports?
  • Could these imports create a cycle?
  • Run arxo analyze --quick before review

Before:

store/user.ts
import { updateSession } from './session';
// store/session.ts
import { clearUser } from './user';

After:

store/user.ts
export const userSlice = createSlice({
name: 'user',
reducers: { clearUser }
});
// store/session.ts
export const sessionSlice = createSlice({
name: 'session',
reducers: { updateSession }
});
// store/index.ts
import { userSlice } from './user';
import { sessionSlice } from './session';
// Combine slices (no cycles)
export const store = configureStore({
reducer: {
user: userSlice.reducer,
session: sessionSlice.reducer
}
});

Before:

Header.tsx
import { UserMenu } from './UserMenu';
// UserMenu.tsx
import { Header } from './Header'; // Cycle!

After:

UserMenu.tsx
export function UserMenu() {
// No import of Header
}
// Header.tsx
import { UserMenu } from './UserMenu';
export function Header() {
return (
<header>
<UserMenu />
</header>
);
}

”I Fixed the Cycle but SCC Still Reports It”

Section titled “”I Fixed the Cycle but SCC Still Reports It””

Cause: Cache not cleared

Solution:

Terminal window
arxo cache clear
arxo analyze --metric scc

Approach:

  1. Break into smaller pieces
  2. Fix one pair at a time
  3. Use --only-changed to verify progress

Solution: Types can have cycles (they’re compile-time only)

Policy:

import_graph:
exclude:
- "**/types/**" # Exclude type-only files from cycle checks

Terminal window
npm install -g madge
madge --circular --extensions ts,tsx src/
Terminal window
arxo analyze --format json | \
jq '.graphs.import_graph' | \
python to_dot.py | \
dot -Tpng -o graph.png