【Java】Java SOLID原則入門:5つの設計原則をコード例で理解する

Java

こんにちは、かつコーチです。
今回はオブジェクト指向設計の基本原則であるSOLID原則を扱います。
「名前は聞いたことがあるが、実際のコードにどう反映すればいいか分からない」という方向けの内容です。
前提として、クラス・interface・継承・ポリモーフィズムは理解している前提で進めます。

SOLID原則とは何か

5つの原則の全体像

SOLID原則とは、Robert C. Martin氏が提唱した、変更に強く保守しやすいクラス設計をするための5つの原則の頭文字を取ったものです。

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

いずれも「仕様変更が入ったときに、影響範囲を最小限にする」ことを目的にしています。
筆者は業務で「1つのクラスを直したら別の画面が壊れた」という事故を経験しましたが、その原因の多くはSOLID原則、特にSとDの違反でした。

なぜ今SOLIDを学ぶ必要があるのか

Java 21時点でも、SOLID原則はフレームワーク(Spring等)の設計思想の土台になっています。
依存性注入(DI)の仕組みを理解する前提として、Dの原則を知っておく必要がありますし、Spring Bootの@Service@Repositoryの分け方はS(単一責任)そのものです。

単一責任・開放閉鎖の原則を理解する

単一責任の原則(SRP)

クラスが変更される理由は1つであるべき、という原則です。

// ❌Before:注文処理とメール送信、DB保存が1クラスに混在
class OrderService {
    void placeOrder(Order order) {
        validate(order);
        saveToDatabase(order);
        sendConfirmationEmail(order);
    }

    private void validate(Order order) { /* 検証ロジック */ }
    private void saveToDatabase(Order order) { /* DB保存 */ }
    private void sendConfirmationEmail(Order order) { /* メール送信 */ }
}

このクラスは「注文検証ルールが変わったとき」「DB構成が変わったとき」「メール文面を変えたいとき」の3つの理由で変更が入ります。
責任を分割すると、変更理由ごとにクラスが独立します。

// ✅After:責任ごとにクラスを分割
class OrderValidator {
    void validate(Order order) { /* 検証ロジック */ }
}

class OrderRepository {
    void save(Order order) { /* DB保存 */ }
}

class OrderNotifier {
    void sendConfirmation(Order order) { /* メール送信 */ }
}

class OrderService {
    private final OrderValidator validator;
    private final OrderRepository repository;
    private final OrderNotifier notifier;

    OrderService(OrderValidator validator, OrderRepository repository, OrderNotifier notifier) {
        this.validator = validator;
        this.repository = repository;
        this.notifier = notifier;
    }

    void placeOrder(Order order) {
        validator.validate(order);
        repository.save(order);
        notifier.sendConfirmation(order);
    }
}

OrderServiceは「注文処理の流れを調整する」責任だけを持ち、実際の検証・保存・通知は各クラスに委譲されています。

開放閉鎖の原則(OCP)

「拡張に対して開いていて、修正に対して閉じているべき」という原則です。
新しい振る舞いを追加するときに、既存クラスの中身を書き換えずに済む設計を指します。

// ❌Before:割引種別が増えるたびにif分岐が増える
class DiscountCalculator {
    double calculate(String type, double price) {
        if (type.equals("SEASONAL")) {
            return price * 0.9;
        } else if (type.equals("MEMBER")) {
            return price * 0.85;
        }
        // 新しい割引が増えるたびにここが肥大化する
        return price;
    }
}
// ✅After:interfaceで振る舞いを切り出す
interface DiscountPolicy {
    double apply(double price);
}

class SeasonalDiscount implements DiscountPolicy {
    public double apply(double price) {
        return price * 0.9;
    }
}

class MemberDiscount implements DiscountPolicy {
    public double apply(double price) {
        return price * 0.85;
    }
}

class DiscountCalculator {
    double calculate(DiscountPolicy policy, double price) {
        return policy.apply(price);
    }
}

新しい割引を追加するときはDiscountPolicyを実装したクラスを1つ増やすだけで済み、DiscountCalculatorは一切変更しません。

リスコフ・インターフェース分離・依存性逆転の原則を理解する

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

サブクラスは、親クラスと置き換えても動作がおかしくならないべき、という原則です。
典型的な違反例が「正方形は長方形の一種」というモデリングです。

class Rectangle {
    protected int width;
    protected int height;

    void setWidth(int width) { this.width = width; }
    void setHeight(int height) { this.height = height; }
    int area() { return width * height; }
}

// ❌Before:正方形は幅と高さが常に同じという制約を継承で表現しようとする
class Square extends Rectangle {
    @Override
    void setWidth(int width) {
        this.width = width;
        this.height = width; // 高さも変わってしまう
    }

    @Override
    void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

Rectangleを期待するコードにSquareを渡すと、setWidthだけ呼んだつもりがheightまで変わり、呼び出し側の期待を裏切ります。
これはLSP違反です。

// ✅After:継承ではなく別の抽象で表現する
interface Shape {
    int area();
}

class Rectangle implements Shape {
    private final int width;
    private final int height;

    Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

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

class Square implements Shape {
    private final int side;

    Square(int side) { this.side = side; }

    public int area() { return side * side; }
}

「継承関係にすべきか」を判断する基準は「is-aの関係か」だけでなく「親クラスの契約(振る舞いの約束)を壊さずに置換できるか」で考える必要があります。

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

クライアントは、使わないメソッドへの依存を強制されるべきではない、という原則です。

// ❌Before:多機能すぎるinterface
interface Worker {
    void work();
    void eat();
}

// ロボットは食事をしないのに実装を強制される
class RobotWorker implements Worker {
    public void work() { /* 稼働処理 */ }
    public void eat() {
        throw new UnsupportedOperationException();
    }
}
// ✅After:役割ごとにinterfaceを分離する
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class RobotWorker implements Workable {
    public void work() { /* 稼働処理 */ }
}

class HumanWorker implements Workable, Eatable {
    public void work() { /* 稼働処理 */ }
    public void eat() { /* 休憩処理 */ }
}

interfaceを小さく分割することで、RobotWorkerは不要なeat()の実装を強制されなくなります。

依存性逆転の原則(DIP)

上位モジュールは下位モジュールに依存すべきではなく、両方とも抽象に依存すべき、という原則です。

// ❌Before:上位クラスが具体的な下位クラスに直接依存
class MySQLUserRepository {
    void save(String user) { /* MySQL固有の保存処理 */ }
}

class UserService {
    private final MySQLUserRepository repository = new MySQLUserRepository();

    void register(String user) {
        repository.save(user);
    }
}

このままだとDBをMySQLからPostgreSQLに変更したいだけでUserService自体を書き換える必要が出てきます。

// ✅After:抽象(interface)に依存させる
interface UserRepository {
    void save(String user);
}

class MySQLUserRepository implements UserRepository {
    public void save(String user) { /* MySQL固有の保存処理 */ }
}

class UserService {
    private final UserRepository repository;

    UserService(UserRepository repository) {
        this.repository = repository;
    }

    void register(String user) {
        repository.save(user);
    }
}

UserServiceUserRepositoryという抽象にしか依存しないため、実装をMySQLからPostgreSQLに差し替えてもコンストラクタに渡す実装を変えるだけで済みます。
これがSpringのDI(依存性注入)コンテナが担っている役割そのものです。

実務でSOLID原則をどこまで守るべきか

過剰適用に注意する

SOLID原則をすべての小さなクラスに厳密に適用すると、クラス数だけが増えて逆に見通しが悪くなることがあります。
筆者の経験では、「変更が頻繁に入る箇所」「テストを書きたい箇所」から優先的に原則を適用し、変更がほぼ発生しない小さなユーティリティクラスまで無理に分割する必要はないと感じています。

判断の目安

  • 仕様変更のたびに同じクラスを何度も触っている → SRP違反を疑う
  • 新機能追加のたびにif/switch文が伸びている → OCP違反を疑う
  • 具体的なクラス名(new XxxImpl())がビジネスロジックの中に直接出てくる → DIP違反を疑う

すべてを完璧に守ろうとするより、「今どの原則に違反していて、それがどんな痛みを生んでいるか」を意識することが実務では重要です。

まとめ

この記事のポイント

  • SOLID原則は変更に強く保守しやすい設計をするための5つの原則
  • SRP・OCPはクラスの責任分割とinterfaceによる拡張性で実現する
  • LSPは継承関係が「契約を壊さず置換できるか」で判断する
  • ISP・DIPはinterfaceの分離とSpringのDIコンテナが実践している考え方そのもの

次に読むべき記事

  • 代表的デザインパターン(Singleton・Factory・Strategy)の記事へ
  • interfaceの基本を復習したい方は「interfaceの基本」の記事へ

タグ: Java, 上級者向け, 設計

タイトルとURLをコピーしました