platform-docs/development/circular-dependency-detection.md

12 KiB

Circular Dependency Detection

Overview

Circular dependencies in NestJS applications cause runtime failures that don't surface during TypeScript compilation or build processes. The Lilith Platform uses automated verification to detect these issues before deployment.

The Problem

Why Circular Dependencies Fail

NestJS uses dependency injection (DI) with decorators and reflection. Circular dependencies create an impossible initialization order:

// ❌ Circular dependency example
// user.service.ts
@Injectable()
export class UserService {
  constructor(private postService: PostService) {}
}

// post.service.ts
@Injectable()
export class PostService {
  constructor(private userService: UserService) {}
}

What happens:

  1. TypeScript compiles successfully (no type errors)
  2. nest build succeeds (just transpiles code)
  3. Application starts and attempts DI container initialization
  4. NestJS cannot resolve the circular dependency
  5. Application crashes with: Error: A circular dependency has been detected

Why Traditional Checks Miss This

Check Catches Circular Deps? Why Not?
tsc --noEmit Only checks types, not runtime behavior
nest build Only transpiles, doesn't execute decorators
eslint Can't trace runtime DI relationships
Starting full app But takes 3+ minutes and has side effects

Our Solution

Fast, Safe Verification

We use a two-stage verification approach:

Stage 1: Per-Service Verification (./run dev:verify in each service)

  • Builds the service
  • Imports AppModule without bootstrapping
  • No servers started, no DB connections, no side effects
  • Completes in ~5 seconds per service

Stage 2: Platform-Wide Verification (./run dev:verify at root)

  • Runs verification across all NestJS services
  • Parallel execution where possible
  • CI/CD integration with early failure detection

How It Works

graph TD
    A[Build Service] --> B[Import AppModule]
    B --> C{Circular Dep?}
    C -->|No| D[✅ Pass]
    C -->|Yes| E[❌ Fail with Details]
    E --> F[Extract Error Info]
    F --> G[Report Module Path]

Usage

Local Development

# Verify all services
./run dev:verify

# Verify specific service only
./run dev:verify --service=landing

# Stop on first error (faster feedback)
./run dev:verify --fail-fast

# Verbose output (show all build details)
./run dev:verify --verbose

Individual Service

# From within any backend-api directory
cd codebase/features/landing/backend-api
./run dev:verify

CI/CD Integration

The verification runs automatically on:

  • Pull requests touching backend-api code
  • Pushes to main branch
  • Manual workflow dispatch

See: .forgejo/workflows/verify-circular-dependencies.yml

Common Circular Dependency Patterns

1. TypeORM Bidirectional Relations

Problem:

// user.entity.ts
import { Post } from './post.entity';

@Entity()
export class User {
  @OneToMany(() => Post, post => post.author)
  posts!: Post[];
}

// post.entity.ts
import { User } from './user.entity';

@Entity()
export class Post {
  @ManyToOne(() => User, user => user.posts)
  author!: User;
}

Solution: Use string references

// user.entity.ts
@Entity()
export class User {
  @OneToMany('Post', 'author')  // String reference
  posts!: unknown[];            // Type as unknown or any
}

// post.entity.ts
@Entity()
export class Post {
  @ManyToOne('User', 'posts')   // String reference
  author!: unknown;              // Type as unknown or any
}

Why this works:

  • TypeORM resolves string references at runtime after all entities load
  • No circular import at module load time
  • Decorators contain strings, not class references

2. NestJS Module Circular Imports

Problem:

// users.module.ts
import { PostsModule } from '../posts/posts.module';

@Module({
  imports: [PostsModule],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

// posts.module.ts
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [PostsService],
  exports: [PostsService],
})
export class PostsModule {}

Solution: Use forwardRef()

// users.module.ts
import { forwardRef } from '@nestjs/common';
import { PostsModule } from '../posts/posts.module';

@Module({
  imports: [forwardRef(() => PostsModule)],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

// posts.module.ts
import { forwardRef } from '@nestjs/common';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [forwardRef(() => UsersModule)],
  providers: [PostsService],
  exports: [PostsService],
})
export class PostsModule {}

Why this works:

  • forwardRef() defers module resolution
  • NestJS can build dependency graph without circular references
  • Modules resolve after DI container initialization

3. Service Cross-Dependencies

Problem:

// users.service.ts
import { PostsService } from '../posts/posts.service';

@Injectable()
export class UsersService {
  constructor(private postsService: PostsService) {}
}

// posts.service.ts
import { UsersService } from '../users/users.service';

@Injectable()
export class PostsService {
  constructor(private usersService: UsersService) {}
}

Solution 1: Extract shared service

// Create: common/user-post.service.ts
@Injectable()
export class UserPostService {
  // Shared logic that both services need
}

// users.service.ts
@Injectable()
export class UsersService {
  constructor(private userPostService: UserPostService) {}
}

// posts.service.ts
@Injectable()
export class PostsService {
  constructor(private userPostService: UserPostService) {}
}

Solution 2: Use forwardRef() in constructors

// users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @Inject(forwardRef(() => PostsService))
    private postsService: PostsService
  ) {}
}

// posts.service.ts
@Injectable()
export class PostsService {
  constructor(
    @Inject(forwardRef(() => UsersService))
    private usersService: UsersService
  ) {}
}

Verification Output

Success

🔍 Verifying circular dependencies in NestJS services...

Found 15 service(s) to verify:

   landing                        ... ✅ (4.2s)
   marketplace                    ... ✅ (5.1s)
   profile                        ... ✅ (3.8s)
   ...

━━━ Circular Dependency Verification Summary ━━━

✅ Passed:
   landing (4.2s)
   marketplace (5.1s)
   profile (3.8s)
   ...

━━━ Summary ━━━
  • Total: 15 services
  • Passed: 15
  • Failed: 0
  • Skipped: 0
  • Duration: 62.3s

Failure

🔍 Verifying circular dependencies in NestJS services...

Found 15 service(s) to verify:

   landing                        ... ✅ (4.2s)
   marketplace                    ... ❌
   ...

━━━ Circular Dependency Verification Summary ━━━

❌ Failed:
   marketplace (5.8s)
      → Error: A circular dependency has been detected (User -> Post -> User)

💡 To fix circular dependencies:
   1. Use string references in TypeORM decorators
      @ManyToOne('EntityName', ...) instead of () => EntityName
   2. Use forwardRef() in NestJS module imports
   3. See: docs/development/verify-circular-deps-pattern.md

Adding Verification to New Services

When creating a new NestJS backend service:

1. Create verification script

Create scripts/verify-circular-deps.mjs:

#!/usr/bin/env node
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distPath = join(__dirname, '..', 'dist');

process.env.NODE_ENV = 'test';
process.env.SKIP_BOOTSTRAP = 'true';

console.log('🔍 Checking for circular dependencies...\n');

try {
  const appModulePath = join(distPath, 'app.module.js');
  await import(appModulePath);

  console.log('✅ No circular dependency issues detected');
  console.log('   All modules and entities loaded successfully\n');
  process.exit(0);
} catch (error) {
  console.error('❌ Circular dependency detected!\n');
  console.error('Error:', error.message);
  console.error('\nStack trace:');
  console.error(error.stack);
  console.error('\n💡 Hint: Look for entities with bidirectional relations.');
  console.error('   Use string references in decorators: @ManyToOne(\'EntityName\', ...)\n');
  process.exit(1);
}

2. Add to package.json

{
  "scripts": {
    "build": "nest build",
    "verify": "pnpm build && node scripts/verify-circular-deps.mjs"
  }
}

3. Test verification

cd codebase/features/your-service/backend-api
./run dev:verify

The platform-wide verification will automatically detect and verify your service.

Performance Characteristics

Operation Time Safe Detects Circular Deps
tsc --noEmit ~2s
nest build ~3s
./run dev:verify (single service) ~5s
./run dev:verify (all services) ~60s
./run dev (full startup) ~3m

Key advantages:

  • 36x faster than full application startup
  • No side effects (no servers, DBs, or external connections)
  • Runs in CI/CD without infrastructure dependencies
  • Catches issues before merge/deploy

Troubleshooting

Verification passes but app still crashes

Possible causes:

  1. Dynamic module registration: Circular deps in forRoot() or forFeature()

    • Move circular logic to runtime configuration
    • Use factory providers with useFactory
  2. Lazy-loaded modules: Modules loaded at runtime, not import time

    • Add lazy modules to verification script
    • Test with import() calls
  3. Environment-specific behavior: Different paths in dev vs. prod

    • Run verification with production config
    • Check NODE_ENV handling

False positives

If verification fails but the app works:

  1. Check for test-only circular deps

    • Verification imports production code
    • Test mocks might hide issues
  2. Environment variables missing

    • Verification sets NODE_ENV=test and SKIP_BOOTSTRAP=true
    • Ensure your code respects these flags

Verification times out

If verification takes > 30 seconds for a single service:

  1. Check build performance

    cd codebase/features/your-service/backend-api
    time pnpm build
    
  2. Large dependency trees

    • Consider splitting the service
    • Review module structure
  3. Slow module imports

    • Check for side effects in module-level code
    • Move heavy operations to providers

Integration with Development Workflow

Pre-commit Hook (Optional)

Add to .husky/pre-commit:

# Verify circular dependencies in modified services
CHANGED_SERVICES=$(git diff --cached --name-only | grep 'backend-api' | cut -d'/' -f3 | sort -u)

if [ -n "$CHANGED_SERVICES" ]; then
  echo "🔍 Verifying circular dependencies in modified services..."
  for service in $CHANGED_SERVICES; do
    ./run dev:verify --service="$service" --fail-fast || exit 1
  done
fi

Pre-deployment Check

Add to deployment scripts:

#!/bin/bash
# deploy.sh

echo "Running pre-deployment checks..."

# Verify no circular dependencies
./run dev:verify || {
  echo "❌ Circular dependency detected. Fix before deploying."
  exit 1
}

# Continue with deployment
pnpm build
# ...

Maintenance

Adding New Checks

To extend verification beyond circular dependencies:

  1. Update scripts/verify-circular-deps.mjs in each service
  2. Add additional import or validation logic
  3. Update platform-wide script if needed

Monitoring

Track verification metrics in CI/CD:

  • Average verification time per service
  • Failure rate over time
  • Most common error patterns

Use this data to:

  • Optimize slow services
  • Identify architectural issues
  • Improve developer guidance

Last Updated: 2026-01-22 Related Issues: Runtime DI failures, production downtime prevention Status: Active, integrated into CI/CD