<CodeLearn/>
設計パターン レッスン2

SOLID原則

オブジェクト指向設計の5大原則をTypeScriptで理解しよう

SOLID原則とは

SOLIDは、Robert C. Martin(Uncle Bob)が提唱した オブジェクト指向設計の5つの原則の頭文字です。 これらの原則に従うことで、変更に強く、理解しやすく、再利用可能なソフトウェアを作ることができます。

S
Single Responsibility Principle- 単一責任の原則
O
Open/Closed Principle- 開放閉鎖の原則
L
Liskov Substitution Principle- リスコフの置換原則
I
Interface Segregation Principle- インターフェース分離の原則
D
Dependency Inversion Principle- 依存性逆転の原則

S - 単一責任の原則 (SRP)

クラスを変更する理由は、たった一つであるべきです。 一つのクラスは一つの責務だけを持ち、その責務に関することだけを行います。

// 悪い例: Userクラスが複数の責任を持っている
class User {
  name: string;
  email: string;

  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }

  // ユーザーデータの管理(責務1)
  changeName(newName: string) {
    this.name = newName;
  }

  // データベース操作(責務2)
  saveToDatabase() {
    // DB保存ロジック...
  }

  // メール送信(責務3)
  sendWelcomeEmail() {
    // メール送信ロジック...
  }

  // PDF生成(責務4)
  generateReport(): string {
    return `Report for ${this.name}`;
  }
}

// 良い例: 責務ごとにクラスを分離
class User {
  constructor(
    public name: string,
    public email: string
  ) {}

  changeName(newName: string) {
    this.name = newName;
  }
}

class UserRepository {
  async save(user: User): Promise<void> {
    // DB保存ロジック
  }

  async findById(id: string): Promise<User | null> {
    // DB検索ロジック
    return null;
  }
}

class EmailService {
  async sendWelcomeEmail(user: User): Promise<void> {
    // メール送信ロジック
  }
}

class UserReportGenerator {
  generate(user: User): string {
    return `Report for ${user.name}`;
  }
}

ポイント

「変更する理由が一つ」とは、「DBの仕様が変わった」「メールの仕様が変わった」など、 異なる変更理由でクラスを修正しなくて済むということです。

O - 開放閉鎖の原則 (OCP)

拡張に対して開いており、修正に対して閉じているべきです。 既存のコードを変更せずに、新しい機能を追加できるように設計します。

// 悪い例: 新しい割引種別を追加するたびに既存コードを修正
function calculateDiscount(type: string, price: number): number {
  if (type === "student") {
    return price * 0.2;
  } else if (type === "senior") {
    return price * 0.3;
  } else if (type === "member") {  // 追加のたびにif文が増える
    return price * 0.15;
  }
  return 0;
}

// 良い例: インターフェースで拡張ポイントを設ける
interface DiscountStrategy {
  calculate(price: number): number;
}

class StudentDiscount implements DiscountStrategy {
  calculate(price: number): number {
    return price * 0.2;
  }
}

class SeniorDiscount implements DiscountStrategy {
  calculate(price: number): number {
    return price * 0.3;
  }
}

// 新しい割引を追加しても既存コードに触らなくてよい
class MemberDiscount implements DiscountStrategy {
  calculate(price: number): number {
    return price * 0.15;
  }
}

class PriceCalculator {
  calculateDiscount(strategy: DiscountStrategy, price: number): number {
    return strategy.calculate(price);
  }
}

L - リスコフの置換原則 (LSP)

子クラスは親クラスの代わりに使えなければならない。 サブタイプは、スーパータイプの契約(期待される振る舞い)を破ってはいけません。

// 悪い例: LSP違反 - 正方形は長方形のサブタイプとして機能しない
class Rectangle {
  constructor(protected width: number, protected height: number) {}

  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  getArea(): number { return this.width * this.height; }
}

class Square extends Rectangle {
  setWidth(w: number) {
    this.width = w;
    this.height = w;  // 正方形なので高さも変わる
  }
  setHeight(h: number) {
    this.width = h;   // 正方形なので幅も変わる
    this.height = h;
  }
}

// Rectangleを期待するコードが壊れる
function doubleWidth(rect: Rectangle) {
  const oldHeight = rect.getArea() / 10; // 仮に幅10の場合
  rect.setWidth(20);
  // Squareだと高さも変わるので予期しない結果に!
}

// 良い例: 共通インターフェースで正しく設計
interface Shape {
  getArea(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  getArea(): number { return this.width * this.height; }
}

class Square implements Shape {
  constructor(private side: number) {}
  getArea(): number { return this.side * this.side; }
}

// どちらのShapeでも安全に使える
function printArea(shape: Shape) {
  console.log(`面積: ${shape.getArea()}`);
}

I - インターフェース分離の原則 (ISP)

クライアントは使わないメソッドへの依存を強制されるべきではない。 大きなインターフェースは、より小さく具体的なインターフェースに分割すべきです。

// 悪い例: 巨大なインターフェース
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  attendMeeting(): void;
  writeReport(): void;
}

// ロボットWorkerはeat()やsleep()を実装できない!
class RobotWorker implements Worker {
  work() { /* OK */ }
  eat() { throw new Error("ロボットは食べません"); }  // 不自然
  sleep() { throw new Error("ロボットは寝ません"); }  // 不自然
  attendMeeting() { /* OK */ }
  writeReport() { /* OK */ }
}

// 良い例: 役割ごとにインターフェースを分離
interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

interface Reportable {
  writeReport(): void;
}

// 人間: すべてを実装
class HumanWorker implements Workable, Eatable, Sleepable, Reportable {
  work() { console.log("Working..."); }
  eat() { console.log("Eating lunch..."); }
  sleep() { console.log("Sleeping..."); }
  writeReport() { console.log("Writing report..."); }
}

// ロボット: 必要なものだけ実装
class RobotWorker implements Workable, Reportable {
  work() { console.log("Working efficiently..."); }
  writeReport() { console.log("Generating report..."); }
}

D - 依存性逆転の原則 (DIP)

上位モジュールは下位モジュールに依存すべきでない。両方とも抽象に依存すべきである。 具体的な実装ではなく、インターフェース(抽象)に依存するようにします。

// 悪い例: 上位モジュールが下位モジュールの具象に直接依存
class MySQLDatabase {
  save(data: string): void {
    console.log(`MySQL: ${data} を保存`);
  }
}

class UserService {
  private db = new MySQLDatabase();  // 具象クラスに直接依存

  createUser(name: string) {
    this.db.save(name);
    // MySQLからPostgreSQLに変更したい場合、
    // UserServiceのコードを修正する必要がある
  }
}

// 良い例: 抽象(インターフェース)に依存
interface Database {
  save(data: string): void;
  find(id: string): string | null;
}

class MySQLDatabase implements Database {
  save(data: string): void {
    console.log(`MySQL: ${data} を保存`);
  }
  find(id: string): string | null {
    return null;
  }
}

class PostgreSQLDatabase implements Database {
  save(data: string): void {
    console.log(`PostgreSQL: ${data} を保存`);
  }
  find(id: string): string | null {
    return null;
  }
}

class UserService {
  // 抽象(インターフェース)に依存
  constructor(private db: Database) {}

  createUser(name: string) {
    this.db.save(name);
  }
}

// 使う側で具象を注入(Dependency Injection)
const mysqlService = new UserService(new MySQLDatabase());
const pgService = new UserService(new PostgreSQLDatabase());

依存性注入(DI)のメリット

テスト時にモックを注入できる、実装を差し替えやすい、 モジュール間の結合度が下がるなど、多くのメリットがあります。

まとめ

  • SRP - クラスの変更理由は一つだけ。責務を分離する
  • OCP - 既存コードを変更せずに拡張できるように設計する
  • LSP - サブタイプは親タイプと同じように安全に使えること
  • ISP - 巨大なインターフェースは小さく分割する
  • DIP - 具象ではなく抽象に依存する。依存性注入を活用する
  • SOLID原則はすべてが連携しており、組み合わせて使うことで効果を発揮する