SJ blog
architecture
S

信頼度ランク

S 公式ソース確認済み
A 成功実績多数・失敗例少数
B 賛否両論
C 動作未確認・セキュリティリスク高
Z 個人所感

SOLID原則をTypeScriptで学ぶ

オブジェクト指向設計の5原則(SOLID)をTypeScriptのコード例で解説。なぜ必要か・守らないとどうなるかを実践的な観点から紹介します。

一言結論

SOLID原則の本質は「変更の理由ごとに責務を分離する」ことにあり、SとDの2原則(単一責任・依存性逆転)だけでも徹底すれば、機能追加時に既存コードを壊さない設計が自然と生まれる。

SOLIDとは

SOLID は Robert C. Martin が提唱したオブジェクト指向設計の5原則の頭文字。

文字原則名一言で
SSingle Responsibility Principleクラスは1つの責務だけ
OOpen/Closed Principle拡張に開いて、修正に閉じる
LLiskov Substitution Principle派生クラスは基底クラスと置換可能
IInterface Segregation Principle使わないインターフェースに依存させない
DDependency Inversion Principle抽象に依存する

「SOLID を守る = コードが変更に強くなる」が目的。


S: 単一責任の原則

クラスを変更する理由は1つだけであるべき。

// ❌ UserService がユーザー管理・メール送信・レポート生成を全部担当
class UserService {
  async createUser(data: CreateUserInput) { ... }
  async sendWelcomeEmail(user: User) { ... }    // メール送信の責務
  generateUserReport(users: User[]): string { ... } // レポートの責務
}

// ✅ 責務を分ける
class UserService {
  async createUser(data: CreateUserInput): Promise<User> { ... }
}

class EmailService {
  async sendWelcomeEmail(user: User): Promise<void> { ... }
}

class UserReportGenerator {
  generate(users: User[]): string { ... }
}

見分け方: クラスを変更するとしたら「誰のため」か。ユーザー管理ロジックの変更とメール送信仕様の変更は別の理由で起きる → 分けるべき。


O: 開放/閉鎖の原則

拡張(追加)には開いていて、修正には閉じているべき。

// ❌ 新しい支払い方法を追加するたびに processPayment を修正しなければならない
class PaymentProcessor {
  process(type: "credit" | "paypal" | "crypto", amount: number) {
    if (type === "credit") { /* ... */ }
    else if (type === "paypal") { /* ... */ }
    else if (type === "crypto") { /* ... */ }  // 追加のたびにこのif文が増える
  }
}

// ✅ インターフェースで拡張できるように
interface PaymentMethod {
  process(amount: number): Promise<void>;
}

class CreditCardPayment implements PaymentMethod {
  async process(amount: number) { /* クレジットカード処理 */ }
}

class PayPalPayment implements PaymentMethod {
  async process(amount: number) { /* PayPal処理 */ }
}

// 新しい支払い方法を追加しても PaymentProcessor を変更しない
class PaymentProcessor {
  constructor(private method: PaymentMethod) {}
  async process(amount: number) { await this.method.process(amount); }
}

L: リスコフ置換の原則

派生クラスは基底クラスと同じ期待どおりの動作をすべき。

// ❌ Penguin(ペンギン)は Bird の一種だが飛べない
class Bird {
  fly(): void { console.log("飛ぶ"); }
}

class Penguin extends Bird {
  fly(): void {
    throw new Error("ペンギンは飛べない!");  // 基底クラスの約束を破っている
  }
}

function makeBirdFly(bird: Bird) {
  bird.fly();  // Penguin を渡すと実行時エラー
}

// ✅ 共通の抽象を設計し直す
interface Animal { move(): void; }
interface FlyingAnimal extends Animal { fly(): void; }
interface SwimmingAnimal extends Animal { swim(): void; }

class Sparrow implements FlyingAnimal {
  move() { this.fly(); }
  fly() { console.log("飛ぶ"); }
}

class Penguin implements SwimmingAnimal {
  move() { this.swim(); }
  swim() { console.log("泳ぐ"); }
}

I: インターフェース分離の原則

クライアントは使わないメソッドへの依存を強制されるべきでない。

// ❌ 巨大なインターフェース:実装クラスが全メソッドを実装しなければならない
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}

// ロボットはeat/sleepしない
class Robot implements Worker {
  work() { console.log("作業"); }
  eat() { throw new Error("ロボットは食べない"); }   // 意味のない実装
  sleep() { throw new Error("ロボットは寝ない"); }
}

// ✅ インターフェースを分割する
interface Workable { work(): void; }
interface Eatable  { eat(): void; }
interface Sleepable { sleep(): void; }

class Human implements Workable, Eatable, Sleepable {
  work() { }
  eat() { }
  sleep() { }
}

class Robot implements Workable {
  work() { }  // 必要なものだけ実装
}

D: 依存性逆転の原則

上位モジュールは下位モジュールに依存すべきでない。両者は抽象に依存すべき。

// ❌ UserService が MySQLDatabase に直接依存
// DB をPostgreSQLに変えたら UserService も修正が必要
class MySQLDatabase {
  query(sql: string) { /* MySQL固有の実装 */ }
}

class UserService {
  private db = new MySQLDatabase();  // 具体的な実装に依存
  getUser(id: string) { return this.db.query(`SELECT * FROM users WHERE id = ${id}`); }
}

// ✅ 抽象(インターフェース)を挟む
interface Database {
  findById<T>(collection: string, id: string): Promise<T | null>;
}

class MySQLDatabase implements Database {
  async findById<T>(collection: string, id: string): Promise<T | null> { /* ... */ }
}

class InMemoryDatabase implements Database {
  async findById<T>(collection: string, id: string): Promise<T | null> { /* テスト用 */ }
}

// UserService は抽象に依存する(具体的なDBを知らない)
class UserService {
  constructor(private db: Database) {}  // 外から注入(依存性注入)
  async getUser(id: string) { return this.db.findById<User>("users", id); }
}

// 本番: MySQLを使う
const service = new UserService(new MySQLDatabase());

// テスト: InMemoryを使う
const testService = new UserService(new InMemoryDatabase());

まとめ

SOLID を守ると何が嬉しいか:

原則違反した場合のコスト
S一箇所の変更が関係ない部分に波及する
O機能追加のたびに既存コードを変更してバグが混入する
L実行時エラーが発生する、is演算子でのtype checkが増える
I使わないメソッドだらけのクラスが増える
Dテストが困難になる、DBやメールサーバーに密結合

完璧に守ることが目的ではなく、変更・テスト・拡張のコストを下げるための指針として活用する。


参考: Agile Software Development(Robert C. Martin) / Refactoring.Guru - SOLID