TypeScriptで学ぶSOLID原則 — クリーンなコードの設計パターン


SOLID原則は、保守性・拡張性の高いコードを書くための5つの設計原則です。この記事では、TypeScriptでSOLIDを実践する方法を具体例とともに解説します。

SOLIDとは

SOLIDは、オブジェクト指向設計の5つの原則の頭文字です。

  • S: Single Responsibility Principle(単一責任の原則)
  • O: Open/Closed Principle(オープン・クローズドの原則)
  • L: Liskov Substitution Principle(リスコフの置換原則)
  • I: Interface Segregation Principle(インターフェイス分離の原則)
  • D: Dependency Inversion Principle(依存性逆転の原則)

S: 単一責任の原則

クラスは1つの責任だけを持つべきです。

悪い例

class User {
  constructor(
    public name: string,
    public email: string
  ) {}

  // ユーザー管理の責任
  save() {
    // データベースへの保存
    db.users.insert(this);
  }

  // メール送信の責任(異なる責任)
  sendWelcomeEmail() {
    const emailService = new EmailService();
    emailService.send(this.email, 'Welcome!', 'Thank you for joining');
  }

  // バリデーションの責任(異なる責任)
  validate() {
    if (!this.email.includes('@')) {
      throw new Error('Invalid email');
    }
  }
}

良い例

責任ごとにクラスを分離します。

// ユーザーエンティティ(データ保持のみ)
class User {
  constructor(
    public readonly name: string,
    public readonly email: string
  ) {}
}

// データベース操作の責任
class UserRepository {
  async save(user: User): Promise<void> {
    await db.users.insert(user);
  }

  async findByEmail(email: string): Promise<User | null> {
    return db.users.findOne({ email });
  }
}

// バリデーションの責任
class UserValidator {
  validate(user: User): void {
    if (!user.email.includes('@')) {
      throw new Error('Invalid email');
    }
    if (user.name.length < 2) {
      throw new Error('Name too short');
    }
  }
}

// メール送信の責任
class UserNotificationService {
  constructor(private emailService: EmailService) {}

  async sendWelcomeEmail(user: User): Promise<void> {
    await this.emailService.send(
      user.email,
      'Welcome!',
      `Hello ${user.name}, thank you for joining`
    );
  }
}

O: オープン・クローズドの原則

クラスは拡張に対して開いていて、変更に対して閉じているべきです。

悪い例

class PaymentProcessor {
  processPayment(amount: number, method: string) {
    if (method === 'credit_card') {
      // クレジットカード処理
      console.log(`Processing ${amount} via credit card`);
    } else if (method === 'paypal') {
      // PayPal処理
      console.log(`Processing ${amount} via PayPal`);
    } else if (method === 'bitcoin') {
      // Bitcoin処理(新しい決済方法を追加するたびに変更が必要)
      console.log(`Processing ${amount} via Bitcoin`);
    }
  }
}

良い例

抽象化を使って拡張可能にします。

// 支払い方法のインターフェイス
interface PaymentMethod {
  process(amount: number): Promise<void>;
}

// 各決済方法の実装
class CreditCardPayment implements PaymentMethod {
  async process(amount: number): Promise<void> {
    console.log(`Processing ${amount} via credit card`);
    // クレジットカードAPI呼び出し
  }
}

class PayPalPayment implements PaymentMethod {
  async process(amount: number): Promise<void> {
    console.log(`Processing ${amount} via PayPal`);
    // PayPal API呼び出し
  }
}

class BitcoinPayment implements PaymentMethod {
  async process(amount: number): Promise<void> {
    console.log(`Processing ${amount} via Bitcoin`);
    // Bitcoin処理
  }
}

// 変更に閉じていて、拡張に開いている
class PaymentProcessor {
  async processPayment(
    amount: number,
    method: PaymentMethod
  ): Promise<void> {
    await method.process(amount);
  }
}

// 使用例
const processor = new PaymentProcessor();
await processor.processPayment(100, new CreditCardPayment());
await processor.processPayment(200, new BitcoinPayment());

L: リスコフの置換原則

サブクラスは基底クラスと置き換え可能であるべきです。

悪い例

class Bird {
  fly(): void {
    console.log('Flying...');
  }
}

class Penguin extends Bird {
  fly(): void {
    // ペンギンは飛べない!
    throw new Error('Penguins cannot fly');
  }
}

function makeBirdFly(bird: Bird) {
  bird.fly(); // Penguinを渡すと例外が発生
}

良い例

適切な抽象化を行います。

abstract class Bird {
  abstract move(): void;
}

class FlyingBird extends Bird {
  move(): void {
    console.log('Flying...');
  }
}

class Penguin extends Bird {
  move(): void {
    console.log('Swimming...');
  }
}

function makeBirdMove(bird: Bird) {
  bird.move(); // どのBirdでも安全に呼び出せる
}

const eagle = new FlyingBird();
const penguin = new Penguin();

makeBirdMove(eagle);    // "Flying..."
makeBirdMove(penguin);  // "Swimming..."

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

クライアントは使わないメソッドに依存すべきではありません。

悪い例

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

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

class RobotWorker 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');
  }
}

良い例

インターフェイスを分離します。

interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

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

class RobotWorker implements Workable {
  work(): void {
    console.log('Working...');
  }
  // eatとsleepを実装する必要がない
}

// 必要なインターフェイスだけに依存
function manageWorker(worker: Workable) {
  worker.work();
}

D: 依存性逆転の原則

高レベルモジュールは低レベルモジュールに依存すべきではなく、両方とも抽象に依存すべきです。

悪い例

class MySQLDatabase {
  save(data: any): void {
    console.log('Saving to MySQL:', data);
  }
}

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

  createUser(name: string, email: string): void {
    const user = { name, email };
    this.db.save(user);
  }
}

良い例

抽象(インターフェイス)に依存します。

// 抽象
interface Database {
  save(data: any): Promise<void>;
  find(query: any): Promise<any>;
}

// 具象実装
class MySQLDatabase implements Database {
  async save(data: any): Promise<void> {
    console.log('Saving to MySQL:', data);
  }
  async find(query: any): Promise<any> {
    console.log('Finding in MySQL:', query);
    return {};
  }
}

class PostgreSQLDatabase implements Database {
  async save(data: any): Promise<void> {
    console.log('Saving to PostgreSQL:', data);
  }
  async find(query: any): Promise<any> {
    console.log('Finding in PostgreSQL:', query);
    return {};
  }
}

// 抽象に依存
class UserService {
  constructor(private db: Database) {} // 依存性注入

  async createUser(name: string, email: string): Promise<void> {
    const user = { name, email };
    await this.db.save(user);
  }
}

// 使用例
const mysqlService = new UserService(new MySQLDatabase());
const postgresService = new UserService(new PostgreSQLDatabase());

依存性注入(DI)コンテナ

TypeScriptでDIを実装する例です。

// DIコンテナ
class Container {
  private services = new Map<string, any>();

  register<T>(name: string, factory: () => T): void {
    this.services.set(name, factory);
  }

  resolve<T>(name: string): T {
    const factory = this.services.get(name);
    if (!factory) {
      throw new Error(`Service ${name} not found`);
    }
    return factory();
  }
}

// 登録
const container = new Container();
container.register('database', () => new MySQLDatabase());
container.register('userService', () =>
  new UserService(container.resolve('database'))
);

// 解決
const userService = container.resolve<UserService>('userService');

実践例: リファクタリング

SOLID原則を適用してコードを改善します。

Before(SOLID違反)

class OrderProcessor {
  processOrder(orderId: string) {
    // データベース直接アクセス
    const order = db.orders.findById(orderId);

    // バリデーション
    if (order.items.length === 0) {
      throw new Error('Empty order');
    }

    // 在庫チェック
    for (const item of order.items) {
      const stock = db.inventory.findById(item.productId);
      if (stock.quantity < item.quantity) {
        throw new Error('Out of stock');
      }
    }

    // 決済処理
    if (order.paymentMethod === 'credit_card') {
      // クレジットカード処理
    } else if (order.paymentMethod === 'paypal') {
      // PayPal処理
    }

    // メール送信
    sendEmail(order.customerEmail, 'Order confirmed');
  }
}

After(SOLID適用)

// 単一責任
interface OrderRepository {
  findById(id: string): Promise<Order>;
}

interface PaymentGateway {
  charge(amount: number): Promise<void>;
}

interface EmailService {
  send(to: string, subject: string): Promise<void>;
}

interface InventoryService {
  checkAvailability(productId: string, quantity: number): Promise<boolean>;
}

// オープンクローズド + 依存性逆転
class OrderProcessor {
  constructor(
    private orderRepo: OrderRepository,
    private paymentGateway: PaymentGateway,
    private emailService: EmailService,
    private inventoryService: InventoryService
  ) {}

  async processOrder(orderId: string): Promise<void> {
    const order = await this.orderRepo.findById(orderId);

    // バリデーション(別クラスに分離可能)
    this.validateOrder(order);

    // 在庫確認
    await this.checkInventory(order);

    // 決済
    await this.paymentGateway.charge(order.total);

    // 通知
    await this.emailService.send(order.customerEmail, 'Order confirmed');
  }

  private validateOrder(order: Order): void {
    if (order.items.length === 0) {
      throw new Error('Empty order');
    }
  }

  private async checkInventory(order: Order): Promise<void> {
    for (const item of order.items) {
      const available = await this.inventoryService.checkAvailability(
        item.productId,
        item.quantity
      );
      if (!available) {
        throw new Error('Out of stock');
      }
    }
  }
}

まとめ

SOLID原則を適用することで、以下のメリットが得られます。

  • 保守性: 変更の影響範囲が限定される
  • テスト性: 依存を注入することでテストが容易
  • 拡張性: 既存コードを変更せず新機能を追加可能
  • 可読性: 各クラスの責任が明確

TypeScriptの型システムを活用することで、SOLID原則をより厳密に適用できます。