JavaScriptのクラス継承はアンチパターン?複雑化を防ぐコンポジションの重要性と正しい設計手法

JavaScriptのクラス継承とコンポジション設計を比較し、コードの複雑化を防ぐ設計手法を解説する記事のアイキャッチ画像 プログラミング言語

JavaScriptのクラス継承は、一見すると「オブジェクト指向らしい」シンプルな設計に見えます。
しかし、プロジェクトが大きくなるにつれて、継承階層が深くなり、どこで何が定義されているのか分からない「スパゲッティ継承」に陥るケースは少なくありません。
これは、クラス継承が「is-a」関係を前提にしているためです。
例えば「DogはAnimalである」という関係は自然ですが、「AdminUserはUserである」という関係が、画面表示や権限管理まで含めてすべて継承で表現しようとすると、すぐに破綻しがちです。

  • 親クラスを変更すると、予期せぬ副作用で子クラスが壊れる
  • 複数の役割(例:ログ出力可能、キャッシュ可能)を多重継承で表現できない
  • テスト時に特定の振る舞いだけを差し替えたいが、継承だと差し替えが難しい

こうした問題を避けるための有力な代替手段がコンポジション(合成)です。
コンポジションは「has-a」関係を中心に据え、クラスが別のオブジェクトを持つことで振る舞いを組み立てます。
継承が「親クラスの機能をそのまま引き継ぐ」のに対し、コンポジションは「必要な振る舞いを持つオブジェクトを組み合わせて、必要なインターフェースだけを公開する」という発想です。

例えば、ユーザーがログイン履歴を保持し、かつログ出力も行えるようにしたい場合、継承ベースでは「Userを継承したLoggableUser」などを作りがちです。
一方、コンポジションでは「UserがLoggerインスタンスを持つ」形で設計します。

class User {
  constructor(name) {
    this.name = name;
    this.logger = new ConsoleLogger(); // ロガーを「持つ」
  }

  login() {
    this.logger.log(`${this.name} logged in`);
    // ログイン処理
  }
}

このようにすることで、ログ出力の仕組みをUserクラスから切り離し、Loggerの実装を差し替えやすくなります。
また、後から「ファイルにもログを出したい」といった要求が出た場合も、Loggerの実装を追加・切り替えるだけで対応できます。

継承とコンポジションのどちらが適しているかは、設計上の「関係性」によって決まります。
以下の表は、それぞれの特徴を簡潔にまとめたものです。

観点 継承 (Inheritance) コンポジション (Composition)
関係性 is-a(〜である) has-a(〜を持つ)
結合度 高くなりがち 低く保ちやすい
再利用性 親クラス単位 部品単位で細かく再利用可能
柔軟性 階層が固定されやすい 組み合わせで柔軟に変更可能
テスト容易性 親クラスに依存しやすい 部品をモックしやすい

もちろん、クラス継承が常にアンチパターンというわけではありません。
言語仕様やフレームワークが継承を前提にしている場合や、明らかに「is-a」関係が安定しているドメインでは、継承は有効です。
しかし、多くのビジネスロジックやUIコンポーネントでは、「何であるか」よりも「何ができるか」に注目したコンポジション設計の方が、変更に強く、理解しやすいコードを生み出します。

本記事では、JavaScriptにおけるクラス継承が抱えがちな問題点を整理し、コンポジションによる設計がなぜ複雑化を防ぐのかを、具体的なコード例とともに解説します。
また、「いつ継承を使い、いつコンポジションを使うべきか」という判断基準や、既存の継承ベースのコードをコンポジション中心にリファクタリングする実践的な手順も紹介します。

JavaScriptのクラス継承はなぜアンチパターンになりやすいのか

JavaScriptのクラス継承で階層が深くなり、コードが複雑化している様子を表す図

JavaScriptにおけるクラス継承は、一見すると「オブジェクト指向らしい」素直な設計に見えますが、プロジェクトが成長するにつれて、コードの複雑性が急激に増大する傾向があります。
これは、クラス継承がもともと「is-a(〜である)」という強い関係性を前提にしているためです。
JavaScriptの世界では、この前提がしばしば現実の要件と噛み合わず、結果としてアンチパターンになりやすい構造を生み出してしまいます。

クラス継承が抱える典型的な問題点

クラス継承を用いた設計では、以下のような典型的な問題が頻繁に発生します。

  • スパゲッティ継承:継承階層が深くなると、どこでどのメソッドが定義されているのか、どこで上書きされているのかが把握しづらくなります。特にJavaScriptではsuper呼び出しの扱いが複雑になりやすく、バグの温床になります
  • 密結合:子クラスは親クラスの実装に強く依存します。親クラスを変更すると、予期せぬ副作用で子クラスが壊れる「脆い基底クラス問題」が起こりやすくなります
  • 再利用性の低下:継承は「クラス単位」でしか再利用できません。一部の振る舞いだけを再利用したい場合でも、クラス全体を継承する必要があり、細かい単位での再利用が困難です
  • テストのしづらさ:親クラスの振る舞いをモックしたり差し替えたりするのが難しく、単体テストが書きづらくなります。特にブラウザ環境やNode.js環境に依存する処理を親クラスに持たせると、テスト環境の構築が複雑になります

たとえば、次のような継承構造を考えてみます。

class Animal {
  speak() {
    console.log("Some sound");
  }
}

class Dog extends Animal {
  speak() {
    console.log("Woof!");
  }
}

class RobotDog extends Dog {
  speak() {
    super.speak(); // ここで何が呼ばれるか、階層が深いと把握しづらい
    console.log("Beep beep");
  }
}

RobotDogのspeakメソッドがsuper.speak()を呼び出したとき、実際にはDogのspeakが実行されます。
このような暗黙の連鎖が増えると、どこで何が起きているのかを追うのが難しくなり、保守性が大きく低下します。

「is-a」関係の誤用と設計の破綻

クラス継承は、「DogはAnimalである」といったis-a関係が明確で安定している場合には有効です。
しかし、多くのビジネスロジックやUIコンポーネントでは、この関係が曖昧になりがちです。

たとえば、「AdminUserはUserである」という関係は、一見すると自然に見えます。
しかし、AdminUserが「管理者画面を表示する」「権限を管理する」「ログを出力する」といった複数の役割を持つようになると、継承だけで表現するのは困難です。

  • 「AdminUserはUserである」と同時に「Loggerである」と言えるでしょうか?
  • 「AdminUserはUserである」と同時に「PermissionManagerである」と言えるでしょうか?

is-a関係を無理に拡張しようとすると、次のような設計の破綻が起こります。

  • 多重継承の代替としての深い階層:JavaScriptは単一継承しかサポートしていないため、複数の役割を持たせようとすると、継承階層が深くなります。たとえばUser → LoggableUser → AdminUserのような階層は、すぐに複雑化します
  • 混入(mixin)の濫用:mixinを使って機能を追加する方法もありますが、mixin同士の衝突や、thisの扱いが複雑になり、型安全性や可読性を損ねることがあります
  • 責務の混在:継承で機能を追加していくと、1つのクラスが「認証」「ログ出力」「権限管理」「UI表示」など、複数の責務を抱え込み、単一責任の原則から大きく外れます

次の表は、is-a関係が適切な場合と、誤用されやすい場合の例をまとめたものです。

ケース is-a関係が適切か 理由
DogはAnimalである 適切 生物学的な分類として安定している
AdminUserはUserである 曖昧 「ユーザー」という概念は安定しているが、「管理者機能」は役割であり、is-aよりhas-aの方が自然
ButtonはComponentである 適切 UIコンポーネントの階層として安定している
LoggableUserはUserである 不適切 「ログ出力」は振る舞いであり、is-a関係で表現するのは不自然

このように、「何であるか」よりも「何ができるか」に注目した設計の方が、多くのケースで柔軟性と保守性を高められます。
JavaScriptのクラス継承は、この「is-a」の前提を誤用しやすいため、結果としてアンチパターンになりやすいのです。

継承そのものが悪いわけではありませんが、JavaScriptのような動的で柔軟な言語では、コンポジション(合成)を中心に据えた設計の方が、変更に強く、理解しやすいコードを生み出しやすいと言えます。

コンポジション(合成)とは何か

コンポジションによってオブジェクトを組み合わせる様子を示す図

コンポジション(composition)とは、あるオブジェクトが別のオブジェクトを「持つ(has-a)」ことで振る舞いを組み立てる設計手法です。
クラス継承が「is-a(〜である)」関係を前提にしているのに対し、コンポジションは「has-a(〜を持つ)」関係を中心に据えます。
これにより、クラス同士の依存関係を明示的かつ細かく制御できるようになり、柔軟で保守しやすいコードを実現できます。

JavaScriptでは、クラスが他のオブジェクトをプロパティとして保持し、そのオブジェクトのメソッドを呼び出す形でコンポジションを表現します。
たとえば、ユーザーがログイン時にログを出力する機能を持ちたい場合、次のように設計できます。

class Logger {
  log(message) {
    console.log(`[LOG] ${message}`);
  }
}

class User {
  constructor(name) {
    this.name = name;
    this.logger = new Logger(); // Loggerインスタンスを「持つ」
  }

  login() {
    this.logger.log(`${this.name} logged in`);
    // ログイン処理
  }
}

ここでは、UserクラスがLoggerクラスを継承しているわけではありません。
代わりに、UserはLoggerインスタンスを内部に保持し、必要に応じてそのlogメソッドを呼び出しています。
これがコンポジションの基本的な考え方です。

has-a関係で設計する利点

has-a関係で設計することには、以下のような利点があります。

  • 責務の分離:ログ出力の責務はLoggerクラスに、ユーザー管理の責務はUserクラスに分離されます。これにより、単一責任の原則を守りやすくなります
  • 再利用性の向上:LoggerクラスはUser以外のクラスでも再利用できます。たとえばOrderクラスやPaymentクラスでも同じLoggerを使うことができます
  • 差し替えの容易さ:Loggerの実装をファイル出力に変更したい場合、Loggerクラスを拡張するか、別の実装に差し替えるだけで済みます。Userクラス自体を変更する必要はありません
  • テストのしやすさ:Userのテストでは、Loggerをモックに差し替えて「ログが正しく呼ばれたか」を検証できます。継承ベースの設計に比べて、依存関係を切り離しやすくなります

次の表は、継承(is-a)とコンポジション(has-a)の主な違いをまとめたものです。

観点 継承 (is-a) コンポジション (has-a)
関係性 「〜である」 「〜を持つ」
結合度 高くなりがち 低く保ちやすい
再利用単位 クラス全体 部品(オブジェクト)単位
変更の影響範囲 親クラス変更が子クラスに波及しやすい 部品の差し替えで局所化しやすい
テスト容易性 親クラスに依存しやすい 部品をモックしやすい

このように、has-a関係で設計することで、「何ができるか」に注目した部品単位の設計が可能になります。
これは、特にビジネスロジックが複雑になるアプリケーションにおいて、大きなメリットをもたらします。

コンポジションで実現する疎結合な設計

コンポジションを活用すると、クラス同士の結合度(coupling)を低く保つことができます。
結合度が低い設計は、変更に強く、理解しやすいコードを生み出します。

たとえば、先ほどのUserクラスに「メール送信」の機能を追加したいとします。
継承ベースで設計すると、Userを継承したEmailUserのようなクラスを作りがちです。
しかし、コンポジションでは次のように設計できます。

class EmailService {
  send(to, subject, body) {
    // メール送信の実装
  }
}

class User {
  constructor(name, emailService) {
    this.name = name;
    this.emailService = emailService; // メールサービスを「持つ」
  }

  notifyLogin() {
    this.emailService.send(
      this.name,
      "Login Notification",
      "You have logged in successfully."
    );
  }
}

ここでは、UserクラスはEmailServiceの具体的な実装を知りません。
EmailServiceがどのようにメールを送信するか(SMTPを使うか、外部APIを呼ぶか)は、EmailService側の責務です。
Userクラスは、単に「メールを送信するオブジェクト」を持っているだけです。

この設計には、以下のような利点があります。

  • 疎結合:UserとEmailServiceは互いに詳細を知らず、インターフェース(ここではsendメソッド)を通じてのみやり取りします。これにより、一方を変更しても他方に影響が出にくくなります
  • 拡張性:後から「SMSでも通知したい」という要件が出た場合、SmsServiceを追加し、Userに渡すだけで対応できます。Userクラス自体を大きく変更する必要はありません
  • テスト容易性:EmailServiceをモックに差し替えることで、Userのテストを環境に依存せずに実行できます

コンポジションを中心に据えた設計は、「大きなクラスを継承で拡張する」発想から、「小さな部品を組み合わせて振る舞いを構築する」発想への転換を促します。
この転換こそが、JavaScriptにおけるクラス継承のアンチパターンを避け、複雑化を防ぐための鍵となります。

JavaScriptにおける継承とコンポジションの具体的な比較

JavaScriptの継承とコンポジションのコード例を比較する図

JavaScriptでオブジェクト指向設計を行う際、クラス継承とコンポジションのどちらを選ぶかは、コードの保守性や拡張性に大きな影響を与えます。
ここでは、ユーザー管理を例に、継承ベースの設計とコンポジションベースの設計を具体的に比較します。
両者の違いを理解することで、どのような場面でどちらが適しているかを判断しやすくなります。

継承ベースのUserクラス設計例

まず、継承を用いた典型的な設計例を見てみましょう。
ここでは、ユーザーがログインし、ログを出力できるようにすることを想定します。

class User {
  constructor(name) {
    this.name = name;
  }

  login() {
    console.log(`${this.name} logged in`);
    // ログイン処理
  }
}

class LoggableUser extends User {
  login() {
    super.login();
    this.writeLog(`${this.name} logged in`);
  }

  writeLog(message) {
    // ログをファイルやDBに書き込む処理
    console.log(`[LOG] ${message}`);
  }
}

この設計では、LoggableUserはUserを継承し、loginメソッドをオーバーライドしてログ出力を追加しています。
一見すると自然な設計に見えますが、以下のような問題を抱えています。

  • 責務の混在:LoggableUserは「ユーザーとしての振る舞い」と「ログ出力の振る舞い」の両方を持っています。単一責任の原則から外れやすくなります
  • 拡張の困難さ:後から「メール通知もしたい」「権限チェックもしたい」といった要件が出た場合、さらに継承階層を深くする必要があります。たとえばEmailLoggableUserのようなクラスを作ると、階層が複雑になります
  • 再利用性の低さ:ログ出力の機能を他のクラス(例:Orderクラス)でも使いたい場合、LoggableUserを継承するわけにはいきません。ログ出力のロジックを別のクラスに切り出しても、継承構造の中で再利用するのは難しいです

このように、継承ベースの設計は、「機能追加=継承」という発想になりがちで、結果として階層が深く、硬直した構造を生み出します。

コンポジションで書き直したUserクラス設計例

次に、同じ要件をコンポジションで書き直した例を見てみましょう。

class Logger {
  log(message) {
    console.log(`[LOG] ${message}`);
  }
}

class User {
  constructor(name, logger) {
    this.name = name;
    this.logger = logger; // Loggerインスタンスを「持つ」
  }

  login() {
    this.logger.log(`${this.name} logged in`);
    // ログイン処理
  }
}

この設計では、UserクラスはLoggerクラスを継承していません。
代わりに、UserはLoggerインスタンスをプロパティとして保持し、必要に応じてそのlogメソッドを呼び出します。
これがコンポジションの基本的な考え方です。

この設計には、以下のような利点があります。

  • 責務の分離:ログ出力の責務はLoggerクラスに、ユーザー管理の責務はUserクラスに分離されています。それぞれが単一の責務に集中できます
  • 再利用性の向上:LoggerクラスはUser以外のクラスでも再利用できます。たとえばOrderクラスやPaymentクラスでも同じLoggerを使うことができます
  • 差し替えの容易さ:ログ出力をファイルに書き込むFileLoggerに変更したい場合、Loggerの実装を差し替えるだけで済みます。Userクラス自体を変更する必要はありません
  • テストのしやすさ:Userのテストでは、Loggerをモックに差し替えて「ログが正しく呼ばれたか」を検証できます。継承ベースに比べて、依存関係を切り離しやすくなります

さらに、コンポジションを活用すると、複数の機能を柔軟に組み合わせることができます。

class EmailService {
  send(to, subject, body) {
    // メール送信の実装
  }
}

class User {
  constructor(name, logger, emailService) {
    this.name = name;
    this.logger = logger;
    this.emailService = emailService; // メールサービスも「持つ」
  }

  login() {
    this.logger.log(`${this.name} logged in`);
    this.emailService.send(
      this.name,
      "Login Notification",
      "You have logged in successfully."
    );
    // ログイン処理
  }
}

このように、Userクラスは「ログ出力」と「メール送信」の機能を部品として組み合わせるだけで、継承階層を深くすることなく機能を追加できます。

継承とコンポジションのメリット・デメリット比較

最後に、継承とコンポジションのメリット・デメリットを整理します。
以下の表は、両者の特徴を簡潔にまとめたものです。

観点 継承 (Inheritance) コンポジション (Composition)
関係性 is-a(〜である) has-a(〜を持つ)
結合度 高くなりがち 低く保ちやすい
再利用性 親クラス単位 部品単位で細かく再利用可能
柔軟性 階層が固定されやすい 組み合わせで柔軟に変更可能
テスト容易性 親クラスに依存しやすい 部品をモックしやすい
適したケース 安定したis-a関係が明確な場合 役割や機能を組み合わせたい場合

継承が適しているのは、次のようなケースです。

  • 言語仕様やフレームワークが継承を前提にしている場合(例:Reactのクラスコンポーネント)
  • 生物学的な分類のように、is-a関係が明確で安定しているドメイン(例:DogはAnimalである)

一方、コンポジションが適しているのは、次のようなケースです。

  • ビジネスロジックが複雑で、役割や機能を組み合わせたい場合
  • テスト容易性や変更のしやすさを重視する場合
  • 既存のコードをリファクタリングして、結合度を下げたい場合

JavaScriptのような動的で柔軟な言語では、「何であるか」よりも「何ができるか」に注目したコンポジション設計の方が、多くの場面で変更に強く、理解しやすいコードを生み出します。
継承そのものが悪いわけではありませんが、安易に継承を使う前に、「本当にis-a関係なのか」「コンポジションで表現できないか」を一度考えることが、アンチパターンを避けるための重要なステップになります。

コンポジションを活かした設計パターン

コンポジションを使ったデザインパターンの概念図

コンポジション(合成)は、単に「オブジェクトをプロパティとして持つ」という以上の力を発揮します。
特に、デザインパターンと組み合わせることで、変更に強く、拡張性の高い設計を実現できます。
ここでは、コンポジションを活かした代表的なパターンとして、StrategyパターンとDecoratorパターンを取り上げ、JavaScriptでの具体的な実装例とともに解説します。
これらのパターンは、クラス継承に頼らずに振る舞いを差し替えたり、機能を動的に追加したりするための強力な手段となります。

Strategyパターンで振る舞いを差し替える

Strategyパターンは、アルゴリズムや振る舞いをオブジェクトとして切り出し、実行時に差し替え可能にするパターンです。
コンポジションを用いて、あるクラスが「戦略(Strategy)」オブジェクトを持ち、そのオブジェクトのメソッドを呼び出す形で振る舞いを委譲します。

たとえば、ユーザーがログインした際に、環境に応じてログ出力の方法を変えたいとします。
開発環境ではコンソールに出力し、本番環境では外部のログサービスに送信する、といった要件です。
継承ベースで設計すると、ConsoleLoggableUserやRemoteLoggableUserのようなクラスを作りがちですが、Strategyパターンを使うと次のように設計できます。

// 戦略のインターフェース(抽象的な振る舞い)
class LogStrategy {
  log(message) {
    throw new Error("log method must be implemented");
  }
}

// 具体的な戦略1: コンソールに出力
class ConsoleLogStrategy extends LogStrategy {
  log(message) {
    console.log(`[CONSOLE] ${message}`);
  }
}

// 具体的な戦略2: 外部サービスに送信
class RemoteLogStrategy extends LogStrategy {
  log(message) {
    // 外部のログサービスに送信する処理
    console.log(`[REMOTE] ${message}`);
  }
}

// コンテキスト: 戦略を使う側
class User {
  constructor(name, logStrategy) {
    this.name = name;
    this.logStrategy = logStrategy; // 戦略を「持つ」
  }

  login() {
    this.logStrategy.log(`${this.name} logged in`);
    // ログイン処理
  }

  // 実行時に戦略を差し替えるメソッド
  setLogStrategy(strategy) {
    this.logStrategy = strategy;
  }
}

この設計には、以下のような利点があります。

  • 振る舞いの差し替えが容易:Userクラスはログ出力の具体的な実装を知りません。setLogStrategyメソッドで戦略を差し替えるだけで、振る舞いを変更できます
  • 拡張性:新しいログ出力方法(例:ファイルに書き込むFileLogStrategy)を追加したい場合、LogStrategyを実装する新しいクラスを作成し、Userに渡すだけで済みます
  • テストのしやすさ:Userのテストでは、LogStrategyのモックを渡すことで、ログ出力が正しく呼ばれたかを検証できます

Strategyパターンは、「if-elseやswitch-caseで分岐するロジック」をオブジェクトに昇華させることで、コードの見通しを良くし、変更に強くするための有効な手段です。

Decoratorパターンで機能を動的に追加する

Decoratorパターンは、既存のオブジェクトに新しい機能を動的に追加するパターンです。
継承を使わずに、コンポジションでオブジェクトをラップすることで、機能を積み重ねていきます。
JavaScriptでは、関数やオブジェクトをラップする形で簡単に実装できます。

たとえば、既存のUserオブジェクトに、ログ出力機能とメール通知機能を後から追加したいとします。
Decoratorパターンを使うと、次のように設計できます。

class User {
  constructor(name) {
    this.name = name;
  }

  login() {
    console.log(`${this.name} logged in`);
    // ログイン処理
  }
}

// ログ出力デコレータ
function withLogging(user) {
  const originalLogin = user.login;
  user.login = function () {
    console.log(`[LOG] ${this.name} is about to login`);
    originalLogin.call(this);
    console.log(`[LOG] ${this.name} logged in successfully`);
  };
  return user;
}

// メール通知デコレータ
function withEmailNotification(user, emailService) {
  const originalLogin = user.login;
  user.login = function () {
    originalLogin.call(this);
    emailService.send(
      this.name,
      "Login Notification",
      "You have logged in successfully."
    );
  };
  return user;
}

// 使用例
const user = new User("Alice");
const decoratedUser = withEmailNotification(withLogging(user), emailService);
decoratedUser.login();

この設計には、以下のような利点があります。

  • 機能の動的な追加:既存のUserオブジェクトを変更することなく、withLoggingやwithEmailNotificationでラップするだけで機能を追加できます
  • 組み合わせの自由:ログ出力だけが必要な場合、メール通知だけが必要な場合、両方必要な場合など、要件に応じてデコレータを自由に組み合わせられます
  • 単一責任の原則の遵守:各デコレータは「ログ出力」や「メール通知」といった単一の責務に集中しています。Userクラス自体はログイン処理に専念できます

Decoratorパターンは、特に「既存のコードを変更せずに機能を追加したい」場合に有効です。
クラス継承では「機能追加=新しい子クラス」になりがちですが、Decoratorパターンを使うと、実行時にオブジェクトを装飾することで、同じ目的をより柔軟に達成できます。

次の表は、StrategyパターンとDecoratorパターンの主な違いをまとめたものです。

観点 Strategyパターン Decoratorパターン
目的 振る舞いの差し替え 機能の動的な追加
関係性 has-a(戦略を持つ) has-a(ラップする)
変更の単位 アルゴリズム全体 機能の一部
適したケース 条件分岐が多いロジック 既存オブジェクトに機能を追加したい場合

これらのパターンに共通するのは、「継承ではなくコンポジションで振る舞いを組み立てる」という発想です。
JavaScriptのような動的言語では、オブジェクトや関数を柔軟に組み合わせられるため、StrategyパターンやDecoratorパターンを自然な形で実装できます。
クラス継承に頼りがちな設計を見直し、これらのパターンを活用することで、より変更に強く、理解しやすいコードを書くことができるでしょう。

コンポジション中心の設計がテストを容易にする理由

コンポジションによりモックやスタブが差し込みやすくなる図

コンポジション(合成)を中心に据えた設計は、単にコードの見通しを良くするだけでなく、テストのしやすさという観点でも大きなメリットをもたらします。
クラス継承ベースの設計では、親クラスとの強い依存関係がテストを難しくしますが、コンポジションを用いることで、依存関係を明示的に切り離し、モックやスタブを差し込みやすくなります。
ここでは、コンポジション中心の設計がなぜテストを容易にするのかを、具体的な例とともに解説します。

依存性注入(DI)とコンポジションの関係

コンポジション中心の設計とテスト容易性を理解する鍵は、依存性注入(Dependency Injection, DI)との関係にあります。
依存性注入とは、あるオブジェクトが依存する別のオブジェクトを、外部から渡す(注入する)設計手法です。
コンポジションは、この「依存オブジェクトを外部から持つ」という考え方を自然に実現します。

たとえば、ユーザーがログインする際にログを出力するUserクラスを考えます。
継承ベースの設計では、次のようになりがちです。

class User {
  constructor(name) {
    this.name = name;
  }

  login() {
    console.log(`${this.name} logged in`); // ログ出力がハードコードされている
    // ログイン処理
  }
}

この設計では、Userクラスはconsole.logに直接依存しています。
テスト時に「ログが正しく出力されたか」を検証するのは難しく、また本番環境ではファイルや外部サービスにログを送信したい場合にも対応できません。

一方、コンポジションと依存性注入を用いると、次のように設計できます。

class Logger {
  log(message) {
    console.log(`[LOG] ${message}`);
  }
}

class User {
  constructor(name, logger) {
    this.name = name;
    this.logger = logger; // Loggerを「持つ」。依存性を注入する
  }

  login() {
    this.logger.log(`${this.name} logged in`);
    // ログイン処理
  }
}

ここでは、UserクラスはLoggerクラスに依存していますが、その依存はコンストラクタを通じて外部から注入されています。
これが依存性注入の基本的な考え方です。
この設計には、以下のようなテスト上の利点があります。

  • モックの差し替えが容易:テスト時には、Loggerのモックを渡すことができます。モックでは「logメソッドが呼ばれたか」「どのメッセージで呼ばれたか」を記録し、後から検証できます
  • 環境依存の排除:Loggerの実装がファイル出力や外部API呼び出しであっても、テスト時にはモックに差し替えることで、テスト環境に依存せずにUserのロジックを検証できます
  • 単体テストの純粋化:Userのテストは、User自身のロジックに集中できます。ログ出力の詳細はLoggerのテストで別途検証すればよいため、責務が分離されます

次の表は、継承ベースの設計とコンポジションベースの設計におけるテストのしやすさを比較したものです。

観点 継承ベースの設計 コンポジションベースの設計
依存関係 親クラスに強く依存 依存オブジェクトを注入可能
モックの差し替え 困難(親クラスをモックしづらい) 容易(依存オブジェクトをモックに差し替え可能)
テスト環境の構築 親クラスの環境も再現必要 依存オブジェクトをモック化すれば環境不要
責務の分離 親クラスの責務もテスト対象に含まれがち 依存オブジェクトの責務は別テストで検証可能

コンポジション中心の設計は、「何に依存しているか」を明示することで、テスト時の差し替えを容易にします。
これは、単にテストコードを書きやすくするだけでなく、設計そのものの質を高める効果もあります。
依存関係が明示的であるほど、コードの意図が読み取りやすくなり、変更時の影響範囲も把握しやすくなります。

さらに、依存性注入を発展させた依存性注入コンテナ(DI Container)を使うと、依存関係の解決を自動化できます。
たとえば、次のような擬似コードを考えます。

// 依存性注入コンテナの擬似コード
class Container {
  register(key, implementation) {
    this.registry[key] = implementation;
  }

  resolve(key) {
    return this.registry[key]();
  }
}

const container = new Container();
container.register("logger", () => new Logger());
container.register("user", () => new User("Alice", container.resolve("logger")));

const user = container.resolve("user");
user.login();

このように、依存性注入コンテナを使うと、UserがどのLoggerに依存しているかをコンテナが自動的に解決します。
テスト時には、loggerの登録をモックに差し替えるだけで済みます。

コンポジション中心の設計と依存性注入は、「変更に強いコード」と「テストしやすいコード」を同時に実現するための強力な組み合わせです。
クラス継承に頼りがちな設計を見直し、コンポジションとDIを積極的に活用することで、JavaScriptのコード品質を大きく向上させることができるでしょう。

既存の継承ベースコードをコンポジションにリファクタリングする手順

継承ベースのコードをコンポジションに置き換えるリファクタリングの流れ図

既存のコードベースがクラス継承に依存している場合、そのまま放置すると「スパゲッティ継承」や「脆い基底クラス問題」に陥りやすくなります。
しかし、一度にすべてを書き換えるのは現実的ではありません。
ここでは、段階的に継承ベースのコードをコンポジション中心の設計にリファクタリングする手順を、4つのステップに分けて解説します。
各ステップでは、具体的なコード例とともに、どのように責務を切り出し、コンポジションで組み立て直すかを説明します。

ステップ1:継承階層の分析と責務の切り出し

まずは、既存の継承階層を冷静に分析し、各クラスがどのような責務を担っているかを明確にします。
このステップでは、コードを変更するのではなく、「何が起きているか」を理解することが目的です。

  • 継承階層を図やリストで可視化する(例:User → LoggableUser → AdminUser)
  • 各クラスのメソッドを列挙し、どのメソッドが親クラスから継承されているか、どのメソッドがオーバーライドされているかを確認する
  • 各メソッドが担っている責務を簡潔にメモする(例:「認証」「ログ出力」「権限管理」「UI表示」など)

たとえば、次のような継承構造を考えます。

class User {
  constructor(name) {
    this.name = name;
  }

  login() {
    console.log(`${this.name} logged in`);
  }
}

class LoggableUser extends User {
  login() {
    super.login();
    this.writeLog(`${this.name} logged in`);
  }

  writeLog(message) {
    console.log(`[LOG] ${message}`);
  }
}

class AdminUser extends LoggableUser {
  login() {
    super.login();
    this.checkPermissions();
  }

  checkPermissions() {
    console.log(`Checking permissions for ${this.name}`);
  }
}

この階層を分析すると、以下のような責務が混在していることが分かります。

  • User:基本的なユーザー情報とログイン処理
  • LoggableUser:ログ出力の機能
  • AdminUser:権限チェックの機能

この時点で、「ログ出力」と「権限チェック」はUserの本質的な責務ではなく、別のコンポーネントとして切り出せそうな候補であることが見えてきます。

ステップ2:共通機能を独立したコンポーネントに抽出する

次に、ステップ1で特定した「切り出せそうな責務」を、独立したコンポーネント(クラスやオブジェクト)として抽出します。
このステップでは、既存の継承階層を壊さずに、新しいコンポーネントを作成することがポイントです。

上記の例では、「ログ出力」と「権限チェック」を独立したコンポーネントに抽出します。

class Logger {
  log(message) {
    console.log(`[LOG] ${message}`);
  }
}

class PermissionManager {
  checkPermissions(user) {
    console.log(`Checking permissions for ${user.name}`);
  }
}

ここでは、LoggerとPermissionManagerを新しく作成しました。
これらはUser階層とは独立して存在し、単体でテスト可能です。
この段階では、まだ既存のUserクラス階層には手を加えません。
「新しいコンポーネントを作る」ことに集中します。

ステップ3:コンポジションで組み立て直す

新しいコンポーネントが準備できたら、既存の継承階層をコンポジションで置き換えていきます。
このステップでは、以下のような手順を踏みます。

  • 継承階層の末端(例:AdminUser)から順に、新しいコンポーネントをプロパティとして持たせる
  • 親クラスから継承していたメソッドを、新しいコンポーネントのメソッド呼び出しに置き換える
  • 必要に応じて、コンストラクタで依存オブジェクトを注入する(依存性注入)

上記の例では、AdminUserをコンポジションで書き直します。

class User {
  constructor(name, logger, permissionManager) {
    this.name = name;
    this.logger = logger;
    this.permissionManager = permissionManager;
  }

  login() {
    this.logger.log(`${this.name} logged in`);
    this.permissionManager.checkPermissions(this);
  }
}

ここでは、UserクラスがLoggerとPermissionManagerをプロパティとして保持し、loginメソッド内でこれらのコンポーネントを利用しています。
これにより、LoggableUserやAdminUserといった継承階層は不要になり、フラットな構造にリファクタリングできます。

この変更には、以下のような利点があります。

  • 責務の分離:ログ出力と権限チェックの責務がUserから切り離され、それぞれ独立したコンポーネントに移ります
  • 再利用性の向上:LoggerやPermissionManagerは他のクラスでも再利用できます
  • 変更の局所化:ログ出力の仕様を変更したい場合、Loggerクラスだけを修正すれば済みます

ステップ4:テストを書き直し、安全性を確認する

最後に、リファクタリング後のコードが正しく動作することをテストで確認します。
このステップでは、以下のような作業を行います。

  • 既存のテストが通ることを確認する(もしテストがなければ、この機会に作成する)
  • 新しいコンポーネント(Logger、PermissionManager)に対する単体テストを追加する
  • Userクラスのテストでは、依存コンポーネントをモックに差し替え、振る舞いが正しいことを検証する

たとえば、Userのテストでは次のようにモックを使います。

// テスト用のモック
class MockLogger {
  constructor() {
    this.messages = [];
  }

  log(message) {
    this.messages.push(message);
  }
}

class MockPermissionManager {
  constructor() {
    this.checkedUsers = [];
  }

  checkPermissions(user) {
    this.checkedUsers.push(user);
  }
}

// テストケース
const mockLogger = new MockLogger();
const mockPermissionManager = new MockPermissionManager();
const user = new User("Alice", mockLogger, mockPermissionManager);

user.login();

// アサーション
console.assert(mockLogger.messages.includes("Alice logged in"));
console.assert(mockPermissionManager.checkedUsers.includes(user));

このように、モックを使うことで、Userのロジックが正しく動作しているかを環境に依存せずに検証できます。

次の表は、リファクタリング前後のテストのしやすさを比較したものです。

観点 リファクタリング前(継承ベース) リファクタリング後(コンポジションベース)
テスト対象の範囲 親クラスの振る舞いも含まれがち Userのロジックに集中可能
モックの利用 親クラスをモックしづらい 依存コンポーネントをモックに差し替え可能
テスト環境の構築 親クラスの環境も再現必要 モックを使えば環境不要
変更時のテスト 親クラス変更でテストが壊れやすい 依存コンポーネントの変更は別テストで検証可能

この4つのステップを踏むことで、既存の継承ベースコードを安全かつ段階的にコンポジション中心の設計に移行できます。
一度にすべてを変更するのではなく、小さな単位でリファクタリングを進めることが、品質を維持しながら設計を改善するための鍵となります。

継承を使うべきケースとコンポジションを使うべきケース

継承とコンポジションの使い分けを判断するフローチャート

クラス継承とコンポジションは、どちらか一方が常に正しいというわけではありません。
重要なのは、「どのような関係性を表現したいか」に応じて、適切な手法を選ぶことです。
ここでは、継承が適しているケースと、コンポジションが適しているケースを整理し、それぞれの判断基準を具体的な例とともに解説します。
これにより、安易に継承に頼るのではなく、設計意図に沿った選択ができるようになります。

フレームワークや言語仕様が継承を前提としている場合

まず、フレームワークや言語仕様自体が継承を前提にしている場合は、継承を使うのが自然です。
このようなケースでは、フレームワークが提供する基底クラスを継承することで、フレームワークの仕組みと整合性の取れたコードを書くことができます。

たとえば、Reactのクラスコンポーネントでは、React.Componentを継承することが前提となっています。

class MyComponent extends React.Component {
  render() {
    return <div>Hello, {this.props.name}!</div>;
  }
}

ここでは、MyComponentはReact.Componentを継承することで、stateやprops、ライフサイクルメソッドといったReactの仕組みを利用できます。
この場合、継承はフレームワークとの整合性を保つための手段として有効です。

同様に、あるGUIフレームワークがButtonクラスを提供し、それを継承してカスタムボタンを作るような設計も、フレームワークの意図に沿った使い方と言えます。

  • フレームワークが提供する基底クラスを継承する
  • フレームワークのライフサイクルやイベントハンドラを利用する
  • フレームワークの設計思想に沿った拡張を行う

このようなケースでは、継承を使うことで、フレームワークの仕組みを自然に活用できます。
ただし、フレームワークの内部実装に過度に依存すると、将来のバージョンアップで破綻する可能性もあるため、公開APIの範囲内で継承を利用することが重要です。

安定したis-a関係が明確なドメインでの継承利用

次に、is-a関係が明確で、かつ安定しているドメインでは、継承が適している場合があります。
is-a関係とは、「DogはAnimalである」といった、生物学的な分類や、概念的な包含関係がはっきりしている場合を指します。

たとえば、図形の階層を表現する場合を考えます。

class Shape {
  area() {
    throw new Error("area method must be implemented");
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  area() {
    return Math.PI * this.radius * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }
}

ここでは、CircleはShapeであり、RectangleもShapeであるというis-a関係が明確です。
また、この関係は将来も大きく変わる可能性が低く、安定したドメインと言えます。
このような場合、継承を使って共通のインターフェース(areaメソッド)を提供するのは自然な設計です。

一方、ビジネスロジックやUIコンポーネントでは、is-a関係が曖昧になりがちです。
たとえば、「AdminUserはUserである」という関係は一見自然に見えますが、実際には「AdminUserはUserであり、かつ管理者権限を持つ」という役割の追加であることが多いです。
このような場合、is-a関係で無理に表現するよりも、has-a関係で「Userが管理者権限を持つ」と設計した方が柔軟です。

次の表は、継承が適しているケースと、コンポジションが適しているケースの判断基準をまとめたものです。

観点 継承が適しているケース コンポジションが適しているケース
関係性 is-a(〜である)が明確 has-a(〜を持つ)が自然
ドメインの安定性 関係が将来も変わらない 要件変更が頻繁に起こる
再利用の単位 クラス全体で再利用 部品単位で細かく再利用
変更の頻度 親クラスの変更が稀 機能追加や変更が頻繁
テストのしやすさ 親クラスに依存しやすい 部品をモックしやすい

継承が適しているのは、次のような条件が揃っている場合です。

  • is-a関係が明確で、将来も大きく変わらない
  • 親クラスの振る舞いをそのまま引き継ぎたい
  • フレームワークや言語仕様が継承を前提にしている

一方、コンポジションが適しているのは、次のような条件が揃っている場合です。

  • 役割や機能を組み合わせたい(例:ログ出力+メール通知)
  • 要件変更が頻繁で、柔軟な設計が求められる
  • テスト容易性や変更のしやすさを重視する

重要なのは、「継承かコンポジションか」という二択ではなく、「どの関係性を表現したいか」に応じて適切な手法を選ぶことです。
JavaScriptのような動的言語では、コンポジションを中心に据えた設計の方が多くの場面で有利ですが、is-a関係が明確で安定しているドメインでは、継承も有効な選択肢となります。
設計を行う際は、この判断基準を意識することで、よりバランスの取れたコードを書くことができるでしょう。

コンポジション中心設計でJavaScriptのコード品質を高める

コンポジション中心設計によりJavaScriptコードの品質が向上する様子を示す図

JavaScriptにおけるクラス継承は、一見すると「オブジェクト指向らしい」素直な設計に見えますが、プロジェクトが成長するにつれて、コードの複雑性が急激に増大する傾向があります。
これは、クラス継承がもともと「is-a(〜である)」という強い関係性を前提にしているためです。
JavaScriptの世界では、この前提がしばしば現実の要件と噛み合わず、結果としてアンチパターンになりやすい構造を生み出してしまいます。

一方、コンポジション(合成)を中心に据えた設計は、「何であるか」よりも「何ができるか」に注目します。
あるオブジェクトが別のオブジェクトを「持つ(has-a)」ことで振る舞いを組み立てるこのアプローチは、JavaScriptのような動的で柔軟な言語と非常に相性が良いです。
コンポジション中心の設計を採用することで、以下のようなコード品質の向上が期待できます。

  • 責務の分離と単一責任の原則の遵守:ログ出力、メール送信、権限管理といった機能を独立したコンポーネントとして切り出し、それぞれが単一の責務に集中できます。これにより、クラスが肥大化するのを防ぎ、変更時の影響範囲を局所化できます
  • 結合度の低下と変更への強さ:クラス同士が直接継承するのではなく、インターフェースを通じてやり取りするため、結合度が低くなります。一方を変更しても他方に影響が出にくく、要件変更に強い設計を実現できます
  • 再利用性の向上:機能を部品単位で再利用できるため、同じロジックをあちこちでコピー&ペーストする必要がなくなります。これにより、コードの重複を減らし、保守性を高められます
  • テストのしやすさ:依存オブジェクトをモックに差し替えることで、単体テストを環境に依存せずに実行できます。特に、ブラウザ環境や外部APIに依存する処理をコンポーネントとして切り出すことで、テストのハードルを大きく下げられます

コンポジション中心の設計は、単なる「継承の代替」ではありません。
設計思想そのものの転換を促します。
継承ベースの設計では、「機能追加=新しい子クラス」という発想になりがちです。
しかし、コンポジション中心の設計では、「機能追加=新しいコンポーネントを組み合わせる」という発想に変わります。
この転換により、コードはよりモジュール化され、理解しやすい構造になります。

たとえば、ユーザー管理の機能を考えてみましょう。
継承ベースでは、Userを継承したLoggableUserやAdminUserを作りがちです。
しかし、コンポジション中心では、UserがLoggerやPermissionManagerといったコンポーネントを持つ形で設計します。

class User {
  constructor(name, logger, permissionManager) {
    this.name = name;
    this.logger = logger;
    this.permissionManager = permissionManager;
  }

  login() {
    this.logger.log(`${this.name} logged in`);
    this.permissionManager.checkPermissions(this);
    // ログイン処理
  }
}

この設計では、Userクラスはログ出力や権限チェックの具体的な実装を知りません。
それらの詳細はLoggerやPermissionManagerに委譲されています。
これにより、Userクラスは「ユーザーとしての振る舞い」に集中でき、他のコンポーネントはそれぞれの責務に専念できます。

コンポジション中心の設計をさらに推し進めるために、デザインパターンを活用することも有効です。
特に、StrategyパターンやDecoratorパターンは、コンポジションの力を最大限に引き出します。

  • Strategyパターン:アルゴリズムや振る舞いをオブジェクトとして切り出し、実行時に差し替え可能にします。これにより、if-elseやswitch-caseで分岐するロジックをオブジェクトに昇華させ、コードの見通しを良くします
  • Decoratorパターン:既存のオブジェクトに新しい機能を動的に追加します。継承を使わずに、コンポジションでオブジェクトをラップすることで、機能を積み重ねていきます。特に、「既存のコードを変更せずに機能を追加したい」場合に有効です

これらのパターンに共通するのは、「継承ではなくコンポジションで振る舞いを組み立てる」という発想です。
JavaScriptでは、オブジェクトや関数を柔軟に組み合わせられるため、これらのパターンを自然な形で実装できます。

既存のコードベースが継承に依存している場合でも、段階的なリファクタリングによってコンポジション中心の設計に移行できます。
以下のようなステップを踏むことで、安全に設計を改善できます。

  1. 継承階層の分析と責務の切り出し:どのクラスがどの責務を担っているかを明確にします
  2. 共通機能を独立したコンポーネントに抽出する:ログ出力や権限チェックなどを独立したクラスとして作成します
  3. コンポジションで組み立て直す:既存の継承階層をコンポジションで置き換え、フラットな構造にします
  4. テストを書き直し、安全性を確認する:リファクタリング後のコードが正しく動作することをテストで確認します

このプロセスを踏むことで、コードの結合度を下げ、テスト容易性を高め、将来の変更に強い設計を実現できます。

もちろん、コンポジションが常に正解というわけではありません。
フレームワークや言語仕様が継承を前提にしている場合や、is-a関係が明確で安定しているドメインでは、継承も有効な選択肢です。
重要なのは、安易に継承に頼るのではなく、設計意図に沿った手法を選ぶことです。

次の表は、継承とコンポジションの使い分けの判断基準をまとめたものです。

観点 継承が適しているケース コンポジションが適しているケース
関係性 is-a(〜である)が明確 has-a(〜を持つ)が自然
ドメインの安定性 関係が将来も変わらない 要件変更が頻繁に起こる
再利用の単位 クラス全体で再利用 部品単位で細かく再利用
変更の頻度 親クラスの変更が稀 機能追加や変更が頻繁
テストのしやすさ 親クラスに依存しやすい 部品をモックしやすい

コンポジション中心の設計は、JavaScriptのコード品質を総合的に高めるための強力な手段です。
責務の分離、結合度の低下、再利用性の向上、テストのしやすさ——これらの要素が組み合わさることで、変更に強く、理解しやすいコードを生み出します。
クラス継承に頼りがちな設計を見直し、コンポジションを積極的に活用することで、JavaScriptのコードベースをより健全なものにしていくことができるでしょう。

コメント

タイトルとURLをコピーしました