📝
【設計】循環依存を避けたい理由と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は強力な原則ですが、過度な適用は逆効果です。
抽象化のコスト
- インターフェースが増えすぎると、コードの見通しが悪化
- 「どの実装が使われているか」の追跡が困難に
- 変更頻度の低い箇所での抽象化は、複雑性を増すだけ
バランスの取り方
抽象化の判断は、より上位の設計目標(保守性・再利用性・責任範囲)のバランスで決まります:
- 変更が頻繁 → 抽象化の価値が高い
- 変更が稀 → 具象のまま保つ方がシンプル
重要: 循環依存を解消することは目的ではなく、保守性・テスタビリティ向上の手段です。
常に設計目標(変更容易性、理解容易性など)から逆算して判断しましょう。
まとめ
循環依存の本質
- テスタビリティの低下: モック化が困難になり、ユニットテストの範囲が肥大化
- 保守コストの増大: 変更の影響範囲が広がり、テスト書き換えコストが増加
- 階層横断的な問題: モジュールからサブシステムまで、あらゆる階層で同じ構造の問題が現れる
DIPによる解消
- 抽象(インターフェース)に依存することで、依存の向きを逆転させ循環を断ち切る
- この原則は論理階型を超えて適用可能(フラクタル構造)
時代による問題の変遷
- 昔: コンパイル時間の増大が主な問題
- 現代: 保守コスト・テスタビリティが主な問題
設計原則の価値は時代とともに変化しますが、その構造的な普遍性は変わりません。
参考文献
- Robert C. Martin『アジャイルソフトウェア開発の奥義』『クリーンアーキテクチャ』
- Gregory Bateson『精神と自然』『精神の生態学へ』
Discussion