【保守性を高める】良いコードの5つの指針「SOLIDの原則」をJavaScriptでマスターしよう

はじめに

NTT西日本の中川です。
本記事では、オブジェクト指向設計の重要な考え方である 「SOLID(ソリッド)の原則」 を、JavaScriptのサンプルコードと共に解説したいと思います。
本記事は、2026年2月時点の情報に基づきます。

対象読者

本記事が想定する対象読者は以下の通りです。

  • もっと「保守性の高いコード」を書きたい方
  • 過去の自分のコードを見て「修正が怖い」と感じた経験がある方
  • チーム開発で設計の指針を求めているエンジニアの方

目次

1. 目的

プログラムは一度書いて終わりではありません。機能追加や仕様変更は多くのプロジェクトで発生します。設計が不十分だと「一箇所直すと別の場所が壊れる」「今回の修正範囲とは関係がないはずの機能に影響が出てしまう」などの負の連鎖に陥ることがあります。 本記事は、SOLIDの5つの原則を理解することで、変化に強く、テストしやすく、そして何より「メンテナンスしやすい」コードを書けるようになるための指針を提示することを目的としています。
サンプルコードはブラウザのコンソールなどでそのまま実行できますので、試してみてください。

1. S:単一責任の原則 (Single Responsibility Principle)

「一つのクラス(または関数)は、一つのことだけを担当すべき」 という原則です。 この法則を意識するだけでもグッと良いコード構成になってきます。

なぜこの原則が必要か?

一つのクラスに複数の責任を持たせると、以下の問題が発生しやすいためです。

  • 影響範囲の増大: ある機能を修正した際、無関係なはずのもう一方の機能にバグが混入するリスクが高まる。
  • テストの難化: 依存関係が複雑になり、特定の動作だけを検証する単体テストが書きづらくなる。
  • 再利用性の低下: 「ログ機能だけ使いたい」と思っても、ユーザー管理機能と密結合していると切り出せなくなる。

悪い例:複数の機能を持ったなんでも屋さんのクラス

class User {
  constructor(name) { this.name = name; }
  display() { console.log(`User: ${this.name}`); }
  
  // ユーザー情報とは無関係な「ログ保存」の責任まで持っている
  saveLog(message) {
    console.log(`ログを保存しました: ${message}`);
  }
}

良い例:責任を分ける

class User {
  constructor(name) { this.name = name; }
  display() { console.log(`User: ${this.name}`); }
}

class Logger {
  saveLog(message) { console.log(`ログを保存しました: ${message}`); }
}

2. O:開放閉鎖(オープンクローズド)の原則 (Open/Closed Principle)

「拡張に対しては開いていて、修正に対しては閉じているべき」 という原則です。

なぜこの原則が必要か?

  • 既存機能の保護: 新機能追加のたびに「既に動いているコード」を書き換えると、デグレード(先祖返り・品質悪化)のリスクが常に付きまとってしまう。
  • 変更コストの削減: 既存コードに手を加えずに「付け足すだけ」で機能が増やせる状態が、多くのプロジェクトにおいては安全で高速な開発となるため。

悪い例:条件分岐(if/switch)による判定

function calculateArea(shape) {
  if (shape.type === 'square') {
    return shape.size ** 2;
  } else if (shape.type === 'circle') {
    return Math.PI * (shape.radius ** 2);
  }
  // 新しい形が増えるたびに、この既存関数を壊すリスクを負って修正が必要
}

良い例:多態性(ポリモーフィズム)を活用

class Square {
  constructor(size) { this.size = size; }
  area() { return this.size ** 2; }
}

class Circle {
  constructor(radius) { this.radius = radius; }
  area() { return Math.PI * (this.radius ** 2); }
}

// 既存のコードを一切修正せず、新しいクラスを渡すだけで拡張可能
function calculateArea(shape) {
  return shape.area();
}

3. L:リスコフの置換原則 (Liskov Substitution Principle)

「親クラスは、その子クラスでいつでも代用できなければならない」 という原則です。

なぜこの原則が必要か?

  • 予測可能性の維持: 親クラスを継承した子クラスが「親とは全く違う挙動(例外を投げるなど)」をした場合を想像してみてください。そのクラスを使う側は常に「これは特殊な子クラスではないか?」と疑ってコードを書かなければならなくなってしまい、バグの温床になってしまいます。

悪い例:期待を裏切る継承

class Bird {
  fly() { console.log("空を飛びます"); }
}

class Ostrich extends Bird {
  fly() { throw new Error("ダチョウは飛べません"); } // Birdとして扱った時にエラーになる
}

良い例:親の期待に沿う継承

class Sparrow extends Bird {
  fly() { console.log("スズメが空を飛びます"); } // Birdとして扱っても安全に動作する
}

// 呼び出し側は「Birdの子孫」として扱うだけでよく、中身がSparrowでもOstrichでも意識しなくてよい
const bird = new Sparrow();
bird.fly(); // 期待通りの挙動

4. I:インターフェース分離の原則 (Interface Segregation Principle)

「利用しないメソッドを、クラスに無理やり実装させてはいけない」 という原則です。

なぜこの原則が必要か?

  • 不要な依存の排除: 使わない機能まで無理やり実装させられると、その機能に変更があった際、本来関係のないはずのクラスまで再コンパイルや修正の影響を受けてしまいます。

悪い例:使わないメソッドまで要求される

// 「掃除も料理も何でもできる人」を要求してしまう設計
function useWorker(worker) {
  worker.clean();
  worker.cook(); // 掃除だけしたい場合でも、cook() の実装を強制されてしまう
}

// 掃除だけしたいのに、cook() も実装しなければならない
class Janitor {
  clean() { console.log("掃除しました"); }
  cook() { throw new Error("担当外です"); } // 使わないのに実装が強制される
}

良い例:役割の細分化

※ JavaScriptには厳密なInterface構文はありませんが、「必要な機能だけを要求する」設計を意識することで、依存関係をクリーンに保てます。

// 掃除担当。cleanメソッドさえ持っていれば、他の余計な機能は知らなくて良い
function cleanRoom(cleaner) {
  cleaner.clean();
}

// 掃除だけできればよいので、clean() だけ持てばよい
const myCleaner = { clean: () => console.log("掃除しました") };
cleanRoom(myCleaner);

5. D:依存性逆転の原則 (Dependency Inversion Principle)

「具体的なものに依存せず、抽象的なものに依存せよ」 という原則です。

なぜこの原則が必要か?

  • 交換可能性の確保: 特定のデータベースや外部ツールに依存したコードを書くと、ツールの変更やバージョンアップの際にシステム全体を書き換える必要が出てきます。
  • テストの容易性: 抽象に依存していれば、本物のデータベースの代わりに「テスト用のダミー(Mock)」を差し替えることが容易になります。

悪い例:特定の道具に密結合

// 仮に MySQL 専用のクラスがあるとする
class MySQLDatabase {
  save(data) { console.log("MySQLに保存:", data); }
}

class UserStore {
  constructor() {
    this.db = new MySQLDatabase(); // 常にMySQLに依存しきっている
  }
  addUser(name) { this.db.save({ name }); }
}
// テストで偽のDBに差し替えたい、別のDBに変えたい、というときに書き換えが大変

良い例:依存性の注入 (DI)

class UserStore {
  constructor(database) {
    this.database = database; // 「保存機能」を持つ何かであれば、中身はMySQLでもPostgreSQLでも良い
  }
  addUser(name) { this.database.save({ name }); }
}

// 本番では実装を、テストではMockを渡せる
const realDb = { save: (data) => console.log("保存:", data) };
const store = new UserStore(realDb);
store.addUser("中川");

6. まとめ

SOLID原則を意識することで、以下のようなメリットが得られます!

  1. 影響範囲の特定: 修正時の「どこまで壊れるか」が明確になります。
  2. テストコードの簡素化: 各部品が独立しているため、検証が容易です。
  3. チーム開発の円滑化: 共通の指針があることで、コードレビューの質も向上します。

もし、試してみようと思っていただけたなら、まずは 「S(単一責任):この関数は欲張りすぎていないか?」 をチェックすることから始めてみてください。きっと新しい気づきがあるはずです。

執筆者

中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属)
NTT西日本のWEBアプリの開発・運営をしています。
TypeScript, Vue.js, GraphQL, Laravelが好きです。

参考資料・出典

本記事を執筆するにあたり、以下のサイトを参考にしました。
原典となる論文: Design Principles and Design Patterns (PDF)

商標

  • JavaScript は、Oracle Corporation の米国およびその他の国における登録商標です。

© NTT WEST, Inc.