📝

【設計】循環依存を避けたい理由とSOLID原則のDIPで解消

に公開

はじめに

「循環依存は避けるべき」理由が曖昧だったので、調査しました。
この記事では、循環依存の問題をグレゴリー・ベイトソンの論理階型(抽象度の階層構造)の観点から整理し、SOLID原則のDIPがシステムの各階層で機能することを示します。

循環依存の問題点

問題の変遷:時代による視点の変化

昔(〜1990年代)

主な問題: コンパイル時間の増大

A.h includes B.h
B.h includes C.h
C.h includes A.h
→ コンパイラが循環を解決するのに時間がかかる

当時はマシンスペックが低く、ビルド時間が開発効率に直結していました。

現代

主な問題: 保守コストの増大

  • テスタビリティの低下: モック化が困難
  • 変更の影響範囲拡大: 1つの変更が循環内の全てに波及
  • パッケージ化の困難: モジュールを独立させられない
  • コードの理解困難: 依存関係が複雑すぎる
  • テスト書き換えコスト: 循環内のどれかが変わるとテストが壊れる

コンパイル速度は向上しましたが、アジャイル開発・CI/CD・マイクロサービス化により変更頻度が増え、保守性・テスタビリティがより重要になりました。

循環依存がテストに与える影響

非循環依存の場合

ソースコード

// 非循環: A → B → C
class C {
  doSomething(): string {
    return "C's work";
  }
}

class B {
  constructor(private c: C) {}
  
  process(): string {
    return `B processes: ${this.c.doSomething()}`;
  }
}

class A {
  constructor(private b: B) {}
  
  execute(): string {
    return `A executes: ${this.b.process()}`;
  }
}

テストコード

// Aのユニットテスト(Bをモック化)
describe('A', () => {
  it('should execute correctly', () => {
    const mockB = {
      process: jest.fn().mockReturnValue('mocked B')
    };
    
    const a = new A(mockB as any);
    expect(a.execute()).toBe('A executes: mocked B');
  });
});

// Bのユニットテスト(Cをモック化)
describe('B', () => {
  it('should process correctly', () => {
    const mockC = {
      doSomething: jest.fn().mockReturnValue('mocked C')
    };
    
    const b = new B(mockC as any);
    expect(b.process()).toBe('B processes: mocked C');
  });
});

// Cのユニットテスト(依存なし)
describe('C', () => {
  it('should do something', () => {
    const c = new C();
    expect(c.doSomething()).toBe("C's work");
  });
});

ポイント: モックを利用して、各クラスを独立してテストできる

循環依存の場合

ソースコード

// 循環: A → B → C → A
class C {
  constructor(private a: A) {}
  
  doSomething(): string {
    return `C uses: ${this.a.getData()}`;
  }
}

class B {
  constructor(private c: C) {}
  
  process(): string {
    return `B processes: ${this.c.doSomething()}`;
  }
}

class A {
  constructor(private b: B) {}
  
  execute(): string {
    return `A executes: ${this.b.process()}`;
  }
  
  getData(): string {
    return "A's data";
  }
}

テストコード

// Aのテストを書こうとしても...
describe('A', () => {
  it('should execute correctly', () => {
    const mockB = {
      process: jest.fn().mockReturnValue('mocked B')
    };
    
    const a = new A(mockB as any);
    
    // しかし、Bの実装内ではC経由でAが必要になる
    // モックだけでは完結しない!
  });
});

// 結局、統合テストのような形になる
describe('A-B-C cycle', () => {
  it('should work together', () => {
    // A、B、C全てを実際に組み立てる必要がある
    const a = new A(null as any); // 一時的に
    const c = new C(a);
    const b = new B(c);
    a['b'] = b; // 後から注入
    
    expect(a.execute()).toBe(/* 複雑な期待値 */);
  });
});

問題点

  • Bをモック化しても、B→C→Aと戻ってくるため、結局Aの実装が必要
  • ユニットテストの「Unit」が不必要に大きくなる
  • テストの意図が不明確になり、失敗時の原因特定が困難

DIP(依存性逆転の原則)による解消

循環依存はSOLID原則の一つ、**DIP(依存性逆転の原則)**で解消できます。

Before: 循環依存

// ❌ 循環依存
class UserService {
  constructor(private authService: AuthService) {}
  
  getUser(id: string): User {
    if (!this.authService.isAuthenticated()) {
      throw new Error('Not authenticated');
    }
    return { id, name: 'User' };
  }
}

class AuthService {
  constructor(private userService: UserService) {}
  
  isAuthenticated(): boolean {
    // UserServiceを使って認証状態を確認
    return true;
  }
  
  login(userId: string): void {
    const user = this.userService.getUser(userId);
    // ログイン処理
  }
}

After: DIPによる解消

// ✅ インターフェースで依存を逆転

// 抽象(インターフェース)
interface IAuthChecker {
  isAuthenticated(): boolean;
}

interface IUserProvider {
  getUser(id: string): User;
}

// UserServiceは抽象に依存
class UserService implements IUserProvider {
  constructor(private authChecker: IAuthChecker) {}
  
  getUser(id: string): User {
    if (!this.authChecker.isAuthenticated()) {
      throw new Error('Not authenticated');
    }
    return { id, name: 'User' };
  }
}

// AuthServiceも抽象に依存
class AuthService implements IAuthChecker {
  constructor(private userProvider: IUserProvider) {}
  
  isAuthenticated(): boolean {
    return true;
  }
  
  login(userId: string): void {
    const user = this.userProvider.getUser(userId);
    // ログイン処理
  }
}

依存グラフの変化

Before: UserService → AuthService → UserService (循環)

After:  UserService → IAuthChecker ← AuthService
                      IUserProvider ← UserService
        (抽象を介して依存が逆転、循環なし)

各階層の循環依存

ここからが本題です。循環依存の問題は、システムの各階層で同じ構造として現れます。
論理階型とは抽象度の階層構造みたいな意味合いです(フラクタル構造、マトリョーシカみたいな)。

システムの論理階型

レベル1: モジュール(関係性を持つ機能最小単位)
    ↓
レベル2: コンポーネント(モジュールの集合が作る系)
    ↓
レベル3: サブシステム(コンポーネントの集合が作る系)
    ↓
レベル4: システム全体

各階層での循環依存の例

レベル1: モジュール間

// ❌ モジュール間の循環依存

// user.module.ts
import { AuthModule } from './auth.module';

export class UserModule {
  constructor(private authModule: AuthModule) {}
}

// auth.module.ts
import { UserModule } from './user.module';

export class AuthModule {
  constructor(private userModule: UserModule) {}
}

レベル2: コンポーネント間

❌ 循環依存
AuthComponent → UserComponent → ProfileComponent → AuthComponent

レベル3: サブシステム間

❌ 循環依存の例(よくあるアンチパターン)

[認証サービス]
  ↓ ユーザー情報取得APIを呼び出し
[ユーザー管理サービス]
  ↓ 認証トークンの検証を依頼
[認証サービス]

このような循環依存があると:
- 認証サービスとユーザー管理サービスを独立してデプロイできない
- 片方のサービスがダウンすると両方使えなくなる
- テスト時に両方のサービスを立ち上げる必要がある

DIPの階層横断的な適用

重要な洞察: DIPはシステムの各階層でも同じパターンとして適用可能です。

モジュールレベル

// ✅ モジュール間も抽象に依存

// interfaces/auth.interface.ts
export interface IAuthService {
  isAuthenticated(): boolean;
}

// user.module.ts
import { IAuthService } from './interfaces/auth.interface';

export class UserModule {
  constructor(private authService: IAuthService) {}
}

// auth.module.ts
import { IAuthService } from './interfaces/auth.interface';

export class AuthModule implements IAuthService {
  isAuthenticated(): boolean {
    return true;
  }
}

コンポーネントレベル

✅ 依存性逆転
AuthComponent → IUserRepository (interface) ← UserComponent

サブシステムレベル

✅ DIPによる解消(マイクロサービスアーキテクチャの例)

共通の認証基盤(Auth0など)を抽象層として利用:

[認証サービス]
  ↓
[Auth0(抽象認証基盤)]
  ↑
[ユーザー管理サービス]

各サービスは共通の認証基盤(抽象)に依存することで:
- サービス間の直接依存を排除
- 独立したデプロイが可能
- サービスごとのテストが容易

DIP適用時の注意点

DIPは強力な原則ですが、過度な適用は逆効果です。

抽象化のコスト

  • インターフェースが増えすぎると、コードの見通しが悪化
  • 「どの実装が使われているか」の追跡が困難に
  • 変更頻度の低い箇所での抽象化は、複雑性を増すだけ

バランスの取り方

抽象化の判断は、より上位の設計目標(保守性・再利用性・責任範囲)のバランスで決まります:

  • 変更が頻繁 → 抽象化の価値が高い
  • 変更が稀 → 具象のまま保つ方がシンプル

重要: 循環依存を解消することは目的ではなく、保守性・テスタビリティ向上の手段です。
常に設計目標(変更容易性、理解容易性など)から逆算して判断しましょう。

まとめ

循環依存の本質

  1. テスタビリティの低下: モック化が困難になり、ユニットテストの範囲が肥大化
  2. 保守コストの増大: 変更の影響範囲が広がり、テスト書き換えコストが増加
  3. 階層横断的な問題: モジュールからサブシステムまで、あらゆる階層で同じ構造の問題が現れる

DIPによる解消

  • 抽象(インターフェース)に依存することで、依存の向きを逆転させ循環を断ち切る
  • この原則は論理階型を超えて適用可能(フラクタル構造)

時代による問題の変遷

  • 昔: コンパイル時間の増大が主な問題
  • 現代: 保守コスト・テスタビリティが主な問題

設計原則の価値は時代とともに変化しますが、その構造的な普遍性は変わりません。

参考文献

  • Robert C. Martin『アジャイルソフトウェア開発の奥義』『クリーンアーキテクチャ』
  • Gregory Bateson『精神と自然』『精神の生態学へ』

Discussion