閉じる

初心者向けにSOLID原則をわかりやすく解説

ソフトウェア設計の本を読むとよく出てくる「SOLID原則」ですが、初心者のうちは少し抽象的でわかりにくく感じることが多いです。

本記事では、実務でありがちなサンプルと図解を用いながら、SOLID原則をできるだけ具体的に、かつやさしい言葉で解説していきます。

これからオブジェクト指向設計を学びたい方や、リファクタリングの指針が欲しい方に役立つ内容を目指します。

SOLID原則とは何か

SOLID原則の概要

SOLID原則とは、オブジェクト指向設計において「読みやすく」「変更しやすく」「壊れにくい」コードを書くための5つの基本ルールの頭文字を並べたものです。

それぞれの意味は次の通りです。

文字原則名(英語)日本語の概要
SSingle Responsibility Principle単一責任の原則
OOpen/Closed Principle開放閉鎖の原則
LLiskov Substitution Principleリスコフの置換原則
IInterface Segregation Principleインターフェイス分離の原則
DDependency Inversion Principle依存性逆転の原則

SOLIDは理論だけを見ると抽象的ですが、実際には「変更に強いコードを書くためのチェックリスト」として考えると理解しやすくなります。

図のように、SOLID原則は単独で使うものではなく、全体として「長期的に保守しやすい設計」を支える考え方だと捉えると理解しやすくなります。

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

単一責任の原則とは

単一責任の原則は「1つのクラス(またはモジュール)は、1つのことだけを責任として持つべき」という考え方です。

少し噛み砕くと、次のように捉えられます。

  • クラスが「何の変更理由」を持つかに注目する
  • 変更理由が複数あるクラスは、役割を分割した方がよい

悪い例と良い例

ここでは、ユーザー登録を行うクラスの例で考えます。

言語は理解しやすいようにJava風の疑似コードで示します。

単一責任に違反している例

Java
// 悪い例: 1つのクラスに役割を詰め込みすぎている
public class UserService {

    // ユーザー登録
    public void register(String name, String email) {
        // 1. データのバリデーション
        if (name == null || name.isEmpty()) {
            throw new IllegalArgumentException("name is empty");
        }
        if (!email.contains("@")) {
            throw new IllegalArgumentException("email is invalid");
        }

        // 2. DBへの保存処理(仮)
        System.out.println("DBにユーザーを保存しました: " + name + ", " + email);

        // 3. 確認メールの送信処理(仮)
        System.out.println("確認メールを送信しました: " + email);

        // 4. ログ出力
        System.out.println("ユーザー登録完了ログ: " + name);
    }
}

このクラスは、次のように複数の責任を持ってしまっています。

  • 入力値のバリデーション
  • DBへの保存
  • メール送信
  • ログ出力

どれか1つの仕様が変わるたびに、同じクラスを修正する必要があり、コードが壊れやすくなります。

単一責任を守った例

Java
// バリデーション専用クラス
public class UserValidator {
    public void validate(String name, String email) {
        if (name == null || name.isEmpty()) {
            throw new IllegalArgumentException("name is empty");
        }
        if (!email.contains("@")) {
            throw new IllegalArgumentException("email is invalid");
        }
    }
}

// ユーザーを保存するクラス
public class UserRepository {
    public void save(String name, String email) {
        // 実際にはDB処理を書く
        System.out.println("DBにユーザーを保存しました: " + name + ", " + email);
    }
}

// メール送信用クラス
public class MailSender {
    public void sendWelcomeMail(String email) {
        System.out.println("確認メールを送信しました: " + email);
    }
}

// ログ出力用クラス
public class UserLogger {
    public void logRegistered(String name) {
        System.out.println("ユーザー登録完了ログ: " + name);
    }
}

// それらを調整するクラス
public class UserService {

    private final UserValidator validator = new UserValidator();
    private final UserRepository repository = new UserRepository();
    private final MailSender mailSender = new MailSender();
    private final UserLogger logger = new UserLogger();

    public void register(String name, String email) {
        validator.validate(name, email);
        repository.save(name, email);
        mailSender.sendWelcomeMail(email);
        logger.logRegistered(name);
    }
}

このように役割ごとにクラスを分けることで、仕様変更の影響範囲が小さくなり、テストもしやすくなります。

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

開放閉鎖の原則とは

開放閉鎖の原則は「ソフトウェアの拡張に対しては開いており、修正に対しては閉じているべき」という原則です。

この原則は、次のような状況で効果を発揮します。

  • 新しい機能を追加したいが、既存コードは極力触りたくない
  • 既存コードをあまり変更せずに振る舞いを変えたい

条件分岐だらけのコードからの脱却

支払い方法によって処理を切り替える例で考えます。

開放閉鎖の原則に違反している例

Java
// 悪い例: 支払い方法が増えるたびにif文が増えていく
public class PaymentService {

    public void pay(String method, int amount) {
        if (method.equals("credit")) {
            System.out.println("クレジットカードで" + amount + "円支払いました");
        } else if (method.equals("bank")) {
            System.out.println("銀行振込で" + amount + "円支払いました");
        } else if (method.equals("cash")) {
            System.out.println("現金で" + amount + "円支払いました");
        } else {
            throw new IllegalArgumentException("不明な支払い方法です");
        }
    }
}

新しい支払い方法(例: 電子マネー)を追加するたびに、このクラスの中身を修正しなければなりません。

開放閉鎖の原則を守る設計

Java
// 支払い方法の共通インターフェイス
public interface PaymentMethod {
    void pay(int amount);
}

// 具体的な支払い方法1
public class CreditCardPayment implements PaymentMethod {
    @Override
    public void pay(int amount) {
        System.out.println("クレジットカードで" + amount + "円支払いました");
    }
}

// 具体的な支払い方法2
public class BankTransferPayment implements PaymentMethod {
    @Override
    public void pay(int amount) {
        System.out.println("銀行振込で" + amount + "円支払いました");
    }
}

// 具体的な支払い方法3
public class CashPayment implements PaymentMethod {
    @Override
    public void pay(int amount) {
        System.out.println("現金で" + amount + "円支払いました");
    }
}

// 支払い処理を行うサービス
public class PaymentService {

    // 具体クラスではなく、インターフェイス(PaymentMethod)に依存する
    private final PaymentMethod paymentMethod;

    public PaymentService(PaymentMethod paymentMethod) {
        this.paymentMethod = paymentMethod;
    }

    public void pay(int amount) {
        paymentMethod.pay(amount);
    }
}

実行例(メインメソッド)も簡単に示します。

Java
public class Main {
    public static void main(String[] args) {
        PaymentService creditService = new PaymentService(new CreditCardPayment());
        creditService.pay(1000);

        PaymentService bankService = new PaymentService(new BankTransferPayment());
        bankService.pay(2000);
    }
}
実行結果
クレジットカードで1000円支払いました
銀行振込で2000円支払いました

この形であれば、新しい支払い方法を追加するときは新しいクラスを1つ定義するだけで済み、既存のPaymentServiceを変更する必要がありません。

これが「拡張に対して開いている」「修正に対して閉じている」という状態です。

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

リスコフの置換原則とは

リスコフの置換原則は「サブクラスは、その親クラスと置き換えて使っても問題なく動作しなければならない」という原則です。

簡単に言い換えると、「継承を使うときは、『is-a』の関係が本当に成り立っているかをよく考えよう」ということです。

有名な「長方形と正方形」問題

よく例に挙げられるのが「長方形と正方形」です。

問題のある継承関係

Java
// 長方形クラス
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }
    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

// 正方形を長方形として継承
public class Square extends Rectangle {

    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // 正方形なので高さも同じにする
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
        this.width = height; // 正方形なので幅も同じにする
    }
}

一見正しそうですが、次のようなテストコードを考えると問題が見えてきます。

Java
public class Main {
    public static void main(String[] args) {
        Rectangle rect = new Square(); // サブクラスで置き換え

        rect.setWidth(4);
        rect.setHeight(5);

        System.out.println(rect.getArea());
    }
}

期待としては「4×5=20」となってほしいのですが、Squareの実装では幅と高さが常に同じになってしまうので、5×5=25のような結果になります。

これは親クラスとして期待される振る舞いを壊してしまっている状態です。

LSPを守るための考え方

この例で言えば、「正方形は長方形の一種」と数学的には言えるものの、ソフトウェアの振る舞いとしては別物として扱った方が安全です。

リスコフの置換原則を守るには次の点を意識します。

  • 親クラスが保証している前提条件・後提条件を、サブクラスが破っていないか
  • 親クラスとして利用しても、予想外の振る舞いをしないか
  • 無理な継承ではなく、委譲や別の抽象クラスを検討できないか

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

インターフェイス分離の原則とは

インターフェイス分離の原則は「クライアントは自分が使わないメソッドへの依存を強制されるべきではない」という原則です。

要するに、1つのインターフェイスに機能を詰め込みすぎると、使う側が迷惑するということです。

大きすぎるインターフェイスの問題

複合機の機能インターフェイスを考えてみます。

インターフェイス分離に違反している例

Java
// 悪い例: 何でも詰め込んだ巨大インターフェイス
public interface MultiFunctionDevice {
    void print(String content);
    void scan(String content);
    void fax(String content);
    void sendEmail(String content);
}

このインターフェイスを実装するクラスは、たとえ印刷しかできないプリンターであっても他のメソッドを定義しなければなりません。

Java
public class SimplePrinter implements MultiFunctionDevice {

    @Override
    public void print(String content) {
        System.out.println("印刷: " + content);
    }

    @Override
    public void scan(String content) {
        throw new UnsupportedOperationException("スキャン非対応です");
    }

    @Override
    public void fax(String content) {
        throw new UnsupportedOperationException("FAX非対応です");
    }

    @Override
    public void sendEmail(String content) {
        throw new UnsupportedOperationException("メール送信非対応です");
    }
}

使わないメソッドをたくさん持たされており、クラスの責任もあいまいになってしまいます。

インターフェイスを分割した設計

Java
// 役割ごとに分割したインターフェイス
public interface Printer {
    void print(String content);
}

public interface Scanner {
    void scan(String content);
}

public interface Fax {
    void fax(String content);
}

// シンプルなプリンター
public class SimplePrinter implements Printer {
    @Override
    public void print(String content) {
        System.out.println("印刷: " + content);
    }
}

// 複合機は複数のインターフェイスを実装すればよい
public class MultiFunctionMachine implements Printer, Scanner, Fax {

    @Override
    public void print(String content) {
        System.out.println("印刷: " + content);
    }

    @Override
    public void scan(String content) {
        System.out.println("スキャン: " + content);
    }

    @Override
    public void fax(String content) {
        System.out.println("FAX送信: " + content);
    }
}

この設計なら、クライアントは自分が必要な機能だけに依存できますし、実装側も余計なメソッドを持たされません。

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

依存性逆転の原則とは

依存性逆転の原則は「高水準のモジュールは低水準のモジュールに依存してはならない。両者は抽象に依存すべきである」という原則です。

少しやさしく言い換えると、「重要な処理を行うクラスが、細かい実装の詳細に直接依存しないようにしよう」という考え方です。

具体クラスにベタッと依存する問題

ユーザーデータを保存するサービスを例に考えます。

依存性逆転に違反している例

Java
// 低水準モジュール: 具体的なDB保存クラス
public class MySqlUserRepository {
    public void save(String name) {
        System.out.println("MySQLにユーザーを保存: " + name);
    }
}

// 高水準モジュール: ビジネスロジック
public class UserService {

    // 具体クラスに直接依存している
    private final MySqlUserRepository repository = new MySqlUserRepository();

    public void register(String name) {
        // 何らかのビジネスロジック...
        repository.save(name);
    }
}

この状態だと、データ保存先を変更したくなった場合(例えばMySQLからファイル保存に変更)に、UserServiceを直接書き換えなければなりません

抽象に依存する設計

依存性逆転の原則では、次のように抽象(インターフェイス)を間に挟みます。

Java
// 抽象: ユーザー保存のためのインターフェイス
public interface UserRepository {
    void save(String name);
}

// 低水準モジュール1: MySQL実装
public class MySqlUserRepository implements UserRepository {
    @Override
    public void save(String name) {
        System.out.println("MySQLにユーザーを保存: " + name);
    }
}

// 低水準モジュール2: ファイル保存実装
public class FileUserRepository implements UserRepository {
    @Override
    public void save(String name) {
        System.out.println("ファイルにユーザーを保存: " + name);
    }
}

// 高水準モジュール: ビジネスロジック
public class UserService {

    // 具体クラスではなく抽象(UserRepository)に依存する
    private final UserRepository repository;

    // 依存性の注入(コンストラクタインジェクション)
    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public void register(String name) {
        // 何らかのビジネスロジック...
        repository.save(name);
    }
}
Java
public class Main {
    public static void main(String[] args) {
        // MySQL版を使いたいとき
        UserRepository mysqlRepo = new MySqlUserRepository();
        UserService mysqlService = new UserService(mysqlRepo);
        mysqlService.register("Taro");

        // ファイル保存版を使いたいとき
        UserRepository fileRepo = new FileUserRepository();
        UserService fileService = new UserService(fileRepo);
        fileService.register("Hanako");
    }
}
実行結果
MySQLにユーザーを保存: Taro
ファイルにユーザーを保存: Hanako

このように、高水準(ビジネスロジック)は抽象に依存し、具体的な差し替えは外部から注入する形にすることで、変更に強い設計になります。

まとめ

SOLID原則は一見むずかしそうですが、それぞれを噛み砕いて見ると「役割を分ける」「条件分岐を減らす」「無理な継承をしない」「インターフェイスを細かく分ける」「抽象をうまく使う」という実務的な指針の集合です。

すべてを完璧に守る必要はなく、まずはコードレビューやリファクタリングの際に「このクラスは責任が多すぎないか」「ここはインターフェイスで抽象化できないか」と1つずつ意識を向けるだけでも、設計の質は確実に向上します。

日々の開発の中で少しずつSOLID原則を取り入れ、読みやすく変更しやすいコードを目指していきましょう。

設計原則・パターン

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!