SOLID原則を理解する

オブジェクト指向設計でよく耳にするSOLID原則。名前は知っていても、具体的に何を意味するのか忘れてしまうことがあります。この記事では、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)

クラスを変更する理由は、たった一つであるべき。

一つのクラスは一つの責任だけを持つべきという原則です。 「責任」とは「変更の理由」と言い換えられます。

違反例

class UserService {
  createUser(name: string, email: string): User {
    // ユーザー作成ロジック
    const user = new User(name, email);

    // バリデーションもここで
    if (!email.includes('@')) {
      throw new Error('Invalid email');
    }

    // データベース保存もここで
    database.insert('users', user);

    // メール送信もここで
    const html = `<h1>Welcome ${name}!</h1>`;
    emailClient.send(email, 'Welcome', html);

    return user;
  }
}

このクラスには複数の変更理由があります。

  • バリデーションルールが変わったとき
  • データベースの保存方法が変わったとき
  • ウェルカムメールの内容が変わったとき

改善例

class UserValidator {
  validate(email: string): void {
    if (!email.includes('@')) {
      throw new Error('Invalid email');
    }
  }
}

class UserRepository {
  save(user: User): void {
    database.insert('users', user);
  }
}

class WelcomeEmailSender {
  send(user: User): void {
    const html = `<h1>Welcome ${user.name}!</h1>`;
    emailClient.send(user.email, 'Welcome', html);
  }
}

class UserService {
  constructor(
    private validator: UserValidator,
    private repository: UserRepository,
    private emailSender: WelcomeEmailSender
  ) {}

  createUser(name: string, email: string): User {
    this.validator.validate(email);
    const user = new User(name, email);
    this.repository.save(user);
    this.emailSender.send(user);
    return user;
  }
}

各クラスが一つの責任だけを持つようになり、変更の影響範囲が限定されます。

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

ソフトウェアの構成要素は、拡張に対して開いていて、修正に対して閉じているべき。

新しい機能を追加するとき、既存のコードを変更せずに拡張できるようにする原則です。

違反例

class DiscountCalculator {
  calculate(order: Order): number {
    if (order.customerType === 'regular') {
      return 0;
    } else if (order.customerType === 'premium') {
      return order.total * 0.1;
    } else if (order.customerType === 'vip') {
      return order.total * 0.2;
    }
    // 新しい顧客タイプを追加するたびにここを修正...
    return 0;
  }
}

新しい顧客タイプが増えるたびに、このメソッドを修正する必要があります。

改善例

interface DiscountStrategy {
  calculate(order: Order): number;
}

class RegularDiscount implements DiscountStrategy {
  calculate(order: Order): number {
    return 0;
  }
}

class PremiumDiscount implements DiscountStrategy {
  calculate(order: Order): number {
    return order.total * 0.1;
  }
}

class VipDiscount implements DiscountStrategy {
  calculate(order: Order): number {
    return order.total * 0.2;
  }
}

class DiscountCalculator {
  constructor(private strategy: DiscountStrategy) {}

  calculate(order: Order): number {
    return this.strategy.calculate(order);
  }
}

新しい割引タイプを追加するときは、新しいクラスを作成するだけで済みます。既存のコードを修正する必要はありません。

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

サブタイプは、その基底タイプと置換可能でなければならない。

子クラスは親クラスの代わりとして使えなければならないという原則です。子クラスが親クラスの契約(振る舞い)を破ってはいけません。

違反例

class Rectangle {
  constructor(protected width: number, protected height: number) {}

  setWidth(width: number): void {
    this.width = width;
  }

  setHeight(height: number): void {
    this.height = height;
  }

  getArea(): number {
    return this.width * this.height;
  }
}

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

  setHeight(height: number): void {
    this.width = height; // 正方形なので幅も変える
    this.height = height;
  }
}

// 問題が発生するケース
function testRectangle(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(4);
  // Rectangleなら20を期待するが、Squareだと16になる
  console.assert(rect.getArea() === 20);
}

SquareRectangleの契約を破っています。setWidthsetHeightが独立して動作するという前提が崩れています。

改善例

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;
  }
}

継承関係を避け、共通のインターフェースを実装することで、それぞれの図形が独自の振る舞いを持てるようになります。

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

クライアントは、自分が使わないメソッドに依存させられるべきではない。

大きなインターフェースを小さなインターフェースに分割すべきという原則です。

違反例

interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}

class Human implements Worker {
  work(): void { console.log('Working'); }
  eat(): void { console.log('Eating'); }
  sleep(): void { console.log('Sleeping'); }
}

class Robot implements Worker {
  work(): void { console.log('Working'); }
  eat(): void { throw new Error('Robots do not eat'); }  // 不要
  sleep(): void { throw new Error('Robots do not sleep'); }  // 不要
}

Roboteatsleepを実装する必要がありますが、実際には使いません。

改善例

interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

class Human implements Workable, Eatable, Sleepable {
  work(): void { console.log('Working'); }
  eat(): void { console.log('Eating'); }
  sleep(): void { console.log('Sleeping'); }
}

class Robot implements Workable {
  work(): void { console.log('Working'); }
}

インターフェースを分割することで、各クラスは必要なインターフェースだけを実装すればよくなります。

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

上位のモジュールは下位のモジュールに依存してはならない。どちらも抽象に依存すべき。

具体的な実装ではなく、抽象(インターフェース)に依存すべきという原則です。

違反例

class MySQLDatabase {
  save(data: string): void {
    console.log(`Saving to MySQL: ${data}`);
  }
}

class UserRepository {
  private database = new MySQLDatabase(); // 具体的な実装に依存

  save(user: User): void {
    this.database.save(JSON.stringify(user));
  }
}

UserRepositoryMySQLDatabaseという具体的な実装に直接依存しています。 データベースを変更したい場合、UserRepositoryを修正する必要があります。

改善例

interface Database {
  save(data: string): void;
}

class MySQLDatabase implements Database {
  save(data: string): void {
    console.log(`Saving to MySQL: ${data}`);
  }
}

class PostgreSQLDatabase implements Database {
  save(data: string): void {
    console.log(`Saving to PostgreSQL: ${data}`);
  }
}

class UserRepository {
  constructor(private database: Database) {} // 抽象に依存

  save(user: User): void {
    this.database.save(JSON.stringify(user));
  }
}

// 使用時に注入
const repository = new UserRepository(new MySQLDatabase());
// または
const repository2 = new UserRepository(new PostgreSQLDatabase());

UserRepositoryDatabaseインターフェースに依存するようになり、具体的なデータベース実装を差し替えられるようになりました。テスト時にモックを注入することも容易です。

まとめ

原則キーワード
SRP一つのクラスに一つの責任
OCP拡張は可能、修正は不要
LSP子は親の代わりになれる
ISP使わないメソッドに依存しない
DIP具体ではなく抽象に依存

これらの原則は独立しているようで、互いに関連しています。 たとえば、ISPを守ることでLSPを守りやすくなり、DIPを守ることでOCPを実現しやすくなります。

すべての原則を常に完璧に守る必要はありませんが、 コードの設計で迷ったとき、原則を思い出すことでより良い判断ができるようになるかもしれません。

参考文献

  • Robert C. Martin『Clean Architecture』(2017)
  • Robert C. Martin『Agile Software Development』(2002)