SOLID原則入門|オブジェクト指向設計の5原則を実例で理解する

kento_morota 30分で読めます

「機能追加のたびに既存コードを大幅に書き換えなければならない」「あるクラスを変更すると別の場所で予期しないバグが発生する」——こうした問題の根本原因は、多くの場合、設計原則の不在にあります。

SOLID原則は、Robert C. Martin氏が提唱したオブジェクト指向設計の5つの基本原則です。本記事では、各原則をTypeScriptの具体的なコード例(Before/After)で解説し、実務でどう活用するかを実践的に紹介します。

SOLID原則の全体像

SOLIDは、以下の5つの原則の頭文字を取ったものです。

S - Single Responsibility Principle(単一責任の原則)
クラスは変更する理由をただ1つだけ持つべきである。

O - Open/Closed Principle(開放閉鎖の原則)
ソフトウェアの構成要素は、拡張に対して開いており、修正に対して閉じているべきである。

L - Liskov Substitution Principle(リスコフの置換原則)
派生型は基底型と置換可能でなければならない。

I - Interface Segregation Principle(インターフェース分離の原則)
クライアントは、自分が使わないメソッドに依存させられるべきではない。

D - Dependency Inversion Principle(依存性逆転の原則)
上位モジュールは下位モジュールに依存すべきではない。両者とも抽象に依存すべきである。

これら5つの原則は独立したものではなく、互いに補完し合う関係にあります。順番に見ていきましょう。

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

「クラスは変更する理由をただ1つだけ持つべきである」という原則です。言い換えると、1つのクラスは1つの関心事だけに集中すべきです。

違反例と改善

// Bad: UserServiceが複数の責任を持っている
class UserService {
  // 責任1: ユーザーのCRUD操作
  async createUser(userData: CreateUserDto): Promise<User> {
    const user = new User(userData);
    await this.db.save(user);
    return user;
  }

  async findById(id: string): Promise<User | null> {
    return this.db.findOne({ where: { id } });
  }

  // 責任2: メール送信
  async sendWelcomeEmail(user: User): Promise<void> {
    const html = this.renderEmailTemplate('welcome', { name: user.name });
    await this.smtpClient.send({
      to: user.email,
      subject: 'ようこそ!',
      html,
    });
  }

  // 責任3: HTMLテンプレートのレンダリング
  private renderEmailTemplate(template: string, data: object): string {
    // テンプレートエンジンの処理
    return `<h1>Welcome, ${data.name}!</h1>`;
  }

  // 責任4: パスワードのハッシュ化
  async hashPassword(password: string): Promise<string> {
    return bcrypt.hash(password, 10);
  }

  // 責任5: 認証トークンの生成
  generateToken(user: User): string {
    return jwt.sign({ userId: user.id }, SECRET_KEY, { expiresIn: '24h' });
  }

  // 責任6: CSVエクスポート
  async exportUsersToCsv(): Promise<string> {
    const users = await this.db.findAll();
    return users.map(u => `${u.id},${u.name},${u.email}`).join('\n');
  }
}

このクラスは、ユーザー管理、メール送信、テンプレート処理、認証、エクスポートと、少なくとも5つの異なる理由で変更される可能性があります。メールテンプレートの変更がユーザー管理のクラスに影響するのは不自然です。

// Good: 責任ごとにクラスを分離

// ユーザーのデータアクセスに専念
class UserRepository {
  async create(userData: CreateUserDto): Promise<User> {
    const user = new User(userData);
    await this.db.save(user);
    return user;
  }

  async findById(id: string): Promise<User | null> {
    return this.db.findOne({ where: { id } });
  }

  async findAll(): Promise<User[]> {
    return this.db.findAll();
  }
}

// メール送信に専念
class EmailService {
  constructor(
    private smtpClient: SmtpClient,
    private templateEngine: TemplateEngine
  ) {}

  async sendWelcomeEmail(user: User): Promise<void> {
    const html = this.templateEngine.render('welcome', { name: user.name });
    await this.smtpClient.send({
      to: user.email,
      subject: 'ようこそ!',
      html,
    });
  }
}

// 認証に専念
class AuthService {
  async hashPassword(password: string): Promise<string> {
    return bcrypt.hash(password, 10);
  }

  generateToken(user: User): string {
    return jwt.sign({ userId: user.id }, SECRET_KEY, { expiresIn: '24h' });
  }
}

// エクスポートに専念
class UserExporter {
  constructor(private userRepository: UserRepository) {}

  async toCsv(): Promise<string> {
    const users = await this.userRepository.findAll();
    return users.map(u => `${u.id},${u.name},${u.email}`).join('\n');
  }
}

分離後は、メールテンプレートの変更はEmailServiceだけに影響し、認証方式の変更はAuthServiceだけに影響します。変更の影響範囲が局所化され、テストも容易になります。

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

「拡張に対して開いており、修正に対して閉じているべき」という原則です。新しい機能を追加するときに、既存のコードを修正せずに済む設計を目指します。

違反例と改善

// Bad: 新しい割引タイプを追加するたびにクラスを修正する必要がある
class DiscountCalculator {
  calculate(order: Order, discountType: string): number {
    if (discountType === 'percentage') {
      return order.total * (order.discountValue / 100);
    } else if (discountType === 'fixed') {
      return order.discountValue;
    } else if (discountType === 'buy_one_get_one') {
      // 新しい割引を追加→既存コードを修正
      return this.calculateBogo(order);
    } else if (discountType === 'seasonal') {
      // さらに追加→if文が際限なく増える
      return this.calculateSeasonal(order);
    }
    return 0;
  }
}
// Good: インターフェースで拡張ポイントを作る

// 割引戦略のインターフェース
interface DiscountStrategy {
  readonly type: string;
  calculate(order: Order): number;
}

// 各割引タイプをクラスとして実装
class PercentageDiscount implements DiscountStrategy {
  readonly type = 'percentage';

  constructor(private percentage: number) {}

  calculate(order: Order): number {
    return order.total * (this.percentage / 100);
  }
}

class FixedAmountDiscount implements DiscountStrategy {
  readonly type = 'fixed';

  constructor(private amount: number) {}

  calculate(order: Order): number {
    return Math.min(this.amount, order.total);
  }
}

class BuyOneGetOneDiscount implements DiscountStrategy {
  readonly type = 'buy_one_get_one';

  calculate(order: Order): number {
    const cheapestItem = order.items.reduce(
      (min, item) => item.price < min.price ? item : min,
      order.items[0]
    );
    return cheapestItem.price;
  }
}

// DiscountCalculatorは修正不要で新しい割引に対応できる
class DiscountCalculator {
  calculate(order: Order, strategy: DiscountStrategy): number {
    return strategy.calculate(order);
  }
}

// 新しい割引タイプの追加は、新しいクラスを作るだけ
class SeasonalDiscount implements DiscountStrategy {
  readonly type = 'seasonal';

  calculate(order: Order): number {
    const now = new Date();
    const month = now.getMonth() + 1;
    // 12月は20%引き、それ以外は10%引き
    const rate = month === 12 ? 0.2 : 0.1;
    return order.total * rate;
  }
}

改善後は、新しい割引タイプを追加する際にDiscountCalculatorを修正する必要がありません。新しいDiscountStrategyクラスを作成するだけで拡張が完了します。

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

「派生型(サブクラス)は、基底型(親クラス)と置換可能でなければならない」という原則です。親クラスを使っているコードが、サブクラスに置き換えても正しく動作する必要があります。

違反例と改善

// Bad: 有名な「正方形・長方形問題」
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;
  }
}

// Rectangleを期待するコードが壊れる
function resizeRectangle(rect: Rectangle): void {
  rect.setWidth(5);
  rect.setHeight(10);
  // Rectangleなら面積は50になるはず
  console.log(rect.getArea());
  // Squareが渡されると面積は100になり、期待と異なる
}
// Good: 共通のインターフェースで設計し直す

// 読み取り専用のShape インターフェース
interface Shape {
  getArea(): number;
  getPerimeter(): number;
}

class Rectangle implements Shape {
  constructor(
    private readonly width: number,
    private readonly height: number
  ) {}

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

  getPerimeter(): number {
    return 2 * (this.width + this.height);
  }

  // 変更が必要な場合は新しいインスタンスを返す(不変オブジェクト)
  withWidth(width: number): Rectangle {
    return new Rectangle(width, this.height);
  }

  withHeight(height: number): Rectangle {
    return new Rectangle(this.width, height);
  }
}

class Square implements Shape {
  constructor(private readonly side: number) {}

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

  getPerimeter(): number {
    return 4 * this.side;
  }

  withSide(side: number): Square {
    return new Square(side);
  }
}

// Shapeインターフェースを使うコードは、どの実装でも正しく動作する
function printShapeInfo(shape: Shape): void {
  console.log(`面積: ${shape.getArea()}`);
  console.log(`周長: ${shape.getPerimeter()}`);
}

改善のポイントは2つあります。1つ目は、不適切な継承関係(正方形は長方形の一種)を避け、共通のインターフェースで設計し直したこと。2つ目は、不変オブジェクトにすることで、状態変更による予期しない動作を防いでいることです。

実務での適用例

// 通知サービスのLSP準拠設計
interface NotificationSender {
  send(to: string, message: string): Promise<NotificationResult>;
}

interface NotificationResult {
  success: boolean;
  messageId: string;
}

class EmailNotification implements NotificationSender {
  async send(to: string, message: string): Promise<NotificationResult> {
    const result = await this.smtpClient.send({ to, body: message });
    return { success: true, messageId: result.id };
  }
}

class SlackNotification implements NotificationSender {
  async send(to: string, message: string): Promise<NotificationResult> {
    const result = await this.slackClient.postMessage({ channel: to, text: message });
    return { success: true, messageId: result.ts };
  }
}

class SmsNotification implements NotificationSender {
  async send(to: string, message: string): Promise<NotificationResult> {
    const result = await this.twilioClient.messages.create({ to, body: message });
    return { success: true, messageId: result.sid };
  }
}

// どの実装を渡しても正しく動作する
class AlertService {
  constructor(private notificationSender: NotificationSender) {}

  async sendAlert(recipient: string, alertMessage: string): Promise<void> {
    const result = await this.notificationSender.send(recipient, alertMessage);
    if (!result.success) {
      throw new Error('通知の送信に失敗しました');
    }
  }
}

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

「クライアントは、自分が使わないメソッドに依存させられるべきではない」という原則です。大きなインターフェースを小さな専用インターフェースに分割します。

違反例と改善

// Bad: 巨大なインターフェースを強制
interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  attendMeeting(): void;
  writeReport(): void;
  driveCompanyCar(): void;
}

// ロボットにeatやsleepを実装させるのは不自然
class RobotWorker implements Worker {
  work(): void { /* 作業を実行 */ }
  eat(): void { throw new Error('ロボットは食事しません'); }   // 不要なメソッド
  sleep(): void { throw new Error('ロボットは睡眠しません'); } // 不要なメソッド
  attendMeeting(): void { /* 会議に参加 */ }
  writeReport(): void { /* レポートを作成 */ }
  driveCompanyCar(): void { throw new Error('ロボットは運転しません'); }
}
// Good: 小さなインターフェースに分離
interface Workable {
  work(): void;
}

interface Feedable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

interface MeetingAttendable {
  attendMeeting(): void;
}

interface ReportWritable {
  writeReport(): void;
}

// 人間は複数のインターフェースを実装
class HumanWorker implements Workable, Feedable, Sleepable, MeetingAttendable, ReportWritable {
  work(): void { /* 作業を実行 */ }
  eat(): void { /* 食事をする */ }
  sleep(): void { /* 睡眠をとる */ }
  attendMeeting(): void { /* 会議に参加 */ }
  writeReport(): void { /* レポートを作成 */ }
}

// ロボットは必要なインターフェースだけを実装
class RobotWorker implements Workable, ReportWritable {
  work(): void { /* 作業を実行 */ }
  writeReport(): void { /* レポートを自動生成 */ }
}

実務での適用例

// データアクセス層でのISP適用

// Bad: すべてのCRUD操作を含む巨大インターフェース
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(data: Partial<T>): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
  softDelete(id: string): Promise<void>;
  restore(id: string): Promise<void>;
  bulkCreate(data: Partial<T>[]): Promise<T[]>;
  search(query: string): Promise<T[]>;
}

// Good: 目的別のインターフェースに分離
interface Readable<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
}

interface Writable<T> {
  create(data: Partial<T>): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
}

interface Deletable {
  delete(id: string): Promise<void>;
}

interface Searchable<T> {
  search(query: string): Promise<T[]>;
}

// 読み取り専用サービスはReadableだけに依存
class UserProfileService {
  constructor(private userReader: Readable<User>) {}

  async getProfile(userId: string): Promise<UserProfile> {
    const user = await this.userReader.findById(userId);
    if (!user) throw new NotFoundError('User', userId);
    return this.mapToProfile(user);
  }
}

// 管理者サービスは書き込みと削除も必要
class UserAdminService {
  constructor(
    private userReader: Readable<User>,
    private userWriter: Writable<User>,
    private userDeleter: Deletable
  ) {}
}

ISPを適用すると、各コンポーネントが本当に必要な機能だけに依存するため、変更の影響範囲が小さくなり、テスト時のモック作成も簡単になります。

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

「上位モジュールは下位モジュールに依存すべきではない。両者とも抽象に依存すべきである」という原則です。具体的な実装ではなく、インターフェース(抽象)に依存することで、実装の差し替えを容易にします。

違反例と改善

// Bad: 上位モジュールが下位モジュールの具体的な実装に依存
import { MySQLDatabase } from './mysql-database';
import { SendGridEmailClient } from './sendgrid-client';
import { StripePayment } from './stripe-payment';

class OrderService {
  // 具体的な実装クラスに直接依存
  private db = new MySQLDatabase();
  private email = new SendGridEmailClient();
  private payment = new StripePayment();

  async createOrder(orderData: OrderData): Promise<Order> {
    // MySQLに強く結合している
    const order = await this.db.insertOrder(orderData);
    // SendGridに強く結合している
    await this.email.sendOrderConfirmation(order);
    // Stripeに強く結合している
    await this.payment.charge(order.total);
    return order;
  }
}

// 問題点:
// - MySQLからPostgreSQLに変更するには OrderService を修正が必要
// - テスト時に実際のMySQL、SendGrid、Stripeが必要
// Good: 抽象(インターフェース)に依存

// 抽象の定義
interface OrderRepository {
  save(order: Order): Promise<Order>;
  findById(id: string): Promise<Order | null>;
}

interface NotificationService {
  sendOrderConfirmation(order: Order): Promise<void>;
}

interface PaymentGateway {
  charge(amount: number, customerId: string): Promise<PaymentResult>;
}

// 上位モジュール:抽象に依存
class OrderService {
  constructor(
    private orderRepository: OrderRepository,
    private notificationService: NotificationService,
    private paymentGateway: PaymentGateway
  ) {}

  async createOrder(orderData: OrderData): Promise<Order> {
    const order = new Order(orderData);

    await this.paymentGateway.charge(order.total, order.customerId);
    const savedOrder = await this.orderRepository.save(order);
    await this.notificationService.sendOrderConfirmation(savedOrder);

    return savedOrder;
  }
}

// 下位モジュール:抽象を実装
class MySQLOrderRepository implements OrderRepository {
  async save(order: Order): Promise<Order> {
    // MySQL固有の実装
    await this.connection.query('INSERT INTO orders ...', order);
    return order;
  }

  async findById(id: string): Promise<Order | null> {
    const [rows] = await this.connection.query('SELECT * FROM orders WHERE id = ?', [id]);
    return rows[0] || null;
  }
}

class PostgreSQLOrderRepository implements OrderRepository {
  // PostgreSQL固有の実装(インターフェースは同じ)
  async save(order: Order): Promise<Order> { ... }
  async findById(id: string): Promise<Order | null> { ... }
}

// 組み立て(Composition Root)
const orderService = new OrderService(
  new MySQLOrderRepository(dbConnection),
  new SendGridNotification(sendgridApiKey),
  new StripePaymentGateway(stripeSecretKey)
);

// テスト時はモックに差し替え
const testOrderService = new OrderService(
  new InMemoryOrderRepository(),       // メモリ上のテスト用実装
  new MockNotificationService(),       // 通知をモック
  new MockPaymentGateway()             // 決済をモック
);

DIPを適用することで、OrderServiceは具体的なデータベースや外部サービスに一切依存しません。MySQLからPostgreSQLへの移行は、新しいリポジトリクラスを作成して注入先を変えるだけで完了します。

SOLID原則の実践的な活用指針

5つの原則を学んだところで、実務での活用指針をまとめます。

原則を適用すべきタイミング

SOLID原則をすべてのコードに厳密に適用する必要はありません。以下のような場面で特に効果を発揮します。

頻繁に変更が発生する部分
ビジネスロジックや外部サービス連携など、変更頻度が高い部分にSOLID原則を適用すると、変更コストを大幅に削減できます。

チームで共有するコード
複数の開発者が触るコードは、SOLID原則に沿って設計することで、意図の理解と安全な変更が容易になります。

テストが重要な部分
DIPとISPを適用することで、テスト容易性が大幅に向上します。モックの作成が簡単になり、テストカバレッジの向上につながります。

過度な適用を避ける

SOLID原則の過度な適用は、コードの複雑さを不必要に増大させます。

シンプルなCRUD操作
データの作成・読み取り・更新・削除だけの処理に、複雑なインターフェース階層は不要です。

変更の見込みがない部分
将来の変更が見込めない安定したコードに、拡張性のための抽象化を過度に適用する必要はありません。

プロトタイプや検証段階
素早く動くものを作ることが優先される段階では、SOLID原則よりもスピードを優先し、安定化のタイミングでリファクタリングするのが現実的です。

段階的な適用アプローチ

SOLID原則は一度にすべてを適用するのではなく、段階的に取り入れるのが効果的です。

まずはSRP(単一責任)から
最も理解しやすく、効果が大きい原則です。巨大なクラスや関数を見つけたら、責任ごとに分割しましょう。

次にDIP(依存性逆転)
外部サービスやデータベースへの依存をインターフェース経由にすることで、テスト容易性が劇的に向上します。

OCPとISPは必要に応じて
実際に「拡張したいのに既存コードの修正が必要」という場面に直面したときに、OCPを適用します。インターフェースが肥大化してきたらISPを検討します。

まとめ

SOLID原則は、保守性と拡張性の高いソフトウェアを設計するための指針です。本記事のポイントを振り返りましょう。

SRP(単一責任)
クラスの変更理由を1つに限定することで、変更の影響範囲を局所化します。巨大なクラスを見つけたら分割のサインです。

OCP(開放閉鎖)
インターフェースとポリモーフィズムを活用し、既存コードを修正せずに新機能を追加できる設計を目指します。

LSP(リスコフの置換)
サブクラスは親クラスの契約を守り、置換可能であるべきです。不適切な継承を避け、インターフェースベースの設計を検討します。

ISP(インターフェース分離)
大きなインターフェースを分割し、クライアントが不要なメソッドに依存することを防ぎます。

DIP(依存性逆転)
具体的な実装ではなく抽象(インターフェース)に依存することで、実装の差し替えとテスト容易性を実現します。

SOLID原則は目標ではなく手段です。「保守性と拡張性を高める」という目的を常に意識しながら、過度な適用を避けつつ、日々のコーディングに少しずつ取り入れていくことをおすすめします。まずはSRPとDIPから始めてみてください。

#SOLID#オブジェクト指向#設計原則
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう