SOLID原則は、「仕様が変わったときに、直す場所が少なく、関係ない場所が壊れない」コードにするための、オブジェクト指向設計の5つの原則です。 Robert C. Martin が整理し、頭文字を並べて SOLID と呼ばれています。

5原則の早見表(1行・違反のサイン・直し方)

原則 1行で 違反のサイン 直し方
S 単一責任(SRP) 変更を求める相手(部署・役割)ごとに分ける ある部署の依頼で直したら、別の部署の数字が変わった 相手ごとにクラスを分ける。似ていても無理に共通化しない
O 開放閉鎖(OCP) 種類の追加を「既存の修正」ではなく「追加」で済ませる 種類を1つ増やすたびに、同じ switch を何か所も直す 種類ごとのクラスにする。または sealed+switch でコンパイラに漏れを探させる
L リスコフの置換(LSP) 子クラスは親の約束を守る 利用側に instanceof 子クラス の分岐が増える。子を渡すと例外 約束を守れないなら継承しない。約束に「できない場合」を最初から含める
I インターフェース分離(ISP) 使う側が使わないメソッドに依存させない 実装に throw new UnsupportedOperationException() が並ぶ 使う側の目的ごとにインターフェースを分ける
D 依存性逆転(DIP) 業務ルールは具体的な仕組みでなく、約束(インターフェース)に依存する クラスの中で new SmtpMailer() や LocalDate.now()。テストに本物のサーバーが要る インターフェースと Clock をコンストラクタで受け取る

この記事では、5つの原則それぞれを Java 21 の小さな業務コードで「直す前」と「直した後」に分け、同じ仕様変更を入れたときに直したファイル数を git で数えました。

原則 入れた仕様変更 直す前 直した後
S 経理の依頼で所定時間を8→7時間に 1ファイル。人事の数字も40→35時間に変わった 1ファイル。人事の数字は40時間のまま
O 配送方法「店舗受け取り」を追加 4ファイル修正。1か所忘れると実行時に例外 1ファイル追加+1ファイル修正
L ダウンロード商品(取消不可)を追加 追加1+利用側2ファイルに instanceof。直さないと一括取消が途中で止まった 追加1ファイルのみ。利用側は無修正
I CSV 出力に区切り文字の引数を追加 5ファイル(無関係な読み取り専用・テスト用も) 3ファイル
D 通知をメールからチャットに変更 2ファイル。業務ルールのクラスを書き換え 2ファイル。業務ルールのクラスは無修正

確認環境:OpenJDK 21.0.10(Ubuntu 24.04)の javac・java で全コードをコンパイル・実行し、出力をそのまま載せています。直したファイル数は、各サンプルを git リポジトリにして仕様変更を入れ、git diff --cached --stat で数えました(サンプルの Main.java はデモ用のデータ差し替えも含みます)。原典の引用は Robert C. Martin のブログ・著書、Barbara Liskov の論文で確かめました(確認日 2026年10月16日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SOLID原則をわかりやすく|早見表とJavaの直す前・後で比較」

SOLID原則とは:出典と原文

項目 内容
原則を整理した人 Robert C. Martin(通称アンクル・ボブ)。2000年の論文『Design Principles and Design Patterns』で、設計が劣化していく問題への処方箋としてまとめた
「SOLID」という名前 2004年ごろ、Michael Feathers が頭文字を並べて名付けた(Martin『Clean Architecture』による)
開放閉鎖の原則の源流 Bertrand Meyer『Object-Oriented Software Construction』(1988年)
リスコフの置換原則の源流 Barbara Liskov の基調講演「Data Abstraction and Hierarchy」(OOPSLA '87)と、Liskov・Wing の論文(1994年)

Martin は2020年のブログ記事「Solid Relevance」で、5原則を次の1文ずつで説明しています。

原則 Martin の原文(2020年) 日本語にすると
SRP "Gather together the things that change for the same reasons. Separate things that change for different reasons." 同じ理由で変わるものは集め、違う理由で変わるものは分ける
OCP "A Module should be open for extension but closed for modification." モジュールは拡張に開き、修正に閉じる
LSP "A program that uses an interface must not be confused by an implementation of that interface." インターフェースを使うプログラムが、その実装によって混乱させられてはならない
ISP "Keep interfaces small so that users don't end up depending on things they don't need." 使う側が要らないものに依存しないよう、インターフェースを小さく保つ
DIP "Depend in the direction of abstraction. High level modules should not depend upon low level details." 抽象の方向に依存する。上位のモジュールは下位の詳細に依存しない

5つに共通する目的は、ある変更の影響を、関係のない場所に広げないことです。

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

単一責任の原則は、「クラスを変更する理由は1つにする」という原則です。 ここでの「理由」は、機能の数ではなく人です。Martin は2014年のブログで "remember that the reasons for change are people."(変更の理由は人だということを忘れないで)と書き、著書『Clean Architecture』では「モジュールは、ただ1つのアクター(変更を求める人や部署)に対して責任を持つ」と言い換えています。

直す前:経理と人事が同じ計算を共用している

勤務データから、経理部は給与を、人事部は所定内の勤務時間を出します。どちらも「所定時間」を使うので、1つのクラスにまとめました。

// Employee.java:勤務データと、給与・勤務報告の計算をすべて持つクラス
public class Employee {
    static final int CORE_HOURS = 8;          // 所定時間(給与と勤務報告で共用)
    final String name;
    final int[] daily;
    Employee(String name, int[] daily) { this.name = name; this.daily = daily; }

    private int regularHours() {              // 共用の小さなメソッド
        int sum = 0;
        for (int h : daily) sum += Math.min(h, CORE_HOURS);
        return sum;
    }
    int calculatePay() {                      // 経理部が使う
        int overtime = 0;
        for (int h : daily) overtime += Math.max(0, h - CORE_HOURS);
        return regularHours() * 2000 + overtime * 2500;
    }
    int reportHours() {                       // 人事部が使う(所定内の勤務時間)
        return regularHours();
    }
}
// PayrollApp.java(経理部の画面)
public class PayrollApp {
    static String run(Employee e) { return "経理:給与=" + e.calculatePay() + "円"; }
}

// HrReportApp.java(人事部の画面)
public class HrReportApp {
    static String run(Employee e) { return "人事:所定内勤務=" + e.reportHours() + "時間"; }
}

仕様変更:経理部の依頼で、給与計算の所定時間を7時間にする。

----- 変更前の実行
経理:給与=95000円
人事:所定内勤務=40時間
----- 変更したファイル
 Employee.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
----- 変更後の実行
経理:給与=97500円
人事:所定内勤務=35時間

経理のための1行の変更で、人事の勤務報告まで40時間から35時間に変わりました。 コンパイルエラーも例外も出ません。給与側のテストしかなければ、誰も気づかないまま人事の数字が変わります。

直した後:変更を求める相手ごとに分ける

// WorkRecord.java
public record WorkRecord(String name, int[] daily) {}

// PayCalculator.java:変更の理由=経理部の給与ルール
public class PayCalculator {
    static final int CORE_HOURS = 8;
    int calculate(WorkRecord r) {
        int regular = 0, overtime = 0;
        for (int h : r.daily()) {
            regular += Math.min(h, CORE_HOURS);
            overtime += Math.max(0, h - CORE_HOURS);
        }
        return regular * 2000 + overtime * 2500;
    }
}

// HourReporter.java:変更の理由=人事部の報告ルール
public class HourReporter {
    static final int CORE_HOURS = 8;
    int regularHours(WorkRecord r) {
        int sum = 0;
        for (int h : r.daily()) sum += Math.min(h, CORE_HOURS);
        return sum;
    }
}

同じ仕様変更を入れた結果です。

----- 変更前の実行
経理:給与=95000円
人事:所定内勤務=40時間
----- 変更したファイル
 PayCalculator.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
----- 変更後の実行
経理:給与=97500円
人事:所定内勤務=40時間
直す前 直した後
直したファイル 1(Employee.java) 1(PayCalculator.java)
そのファイルを使う画面 経理・人事の2つ 経理だけ
結果が変わった部署 経理と人事 経理だけ
再テストが要る範囲 経理と人事 経理

SRP は「直すファイル数」より「影響が及ぶ範囲」を小さくする原則だと分かります。CORE_HOURS = 8 が2か所にあるのは重複に見えますが、今は同じでも、変える理由(部署)が違う値は、無理に共通化しないほうが安全です。

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

開放閉鎖の原則は、「拡張に対して開き、修正に対して閉じる」ように作る原則です。 新しい種類を足すとき、既存のコードを書き換えずに、新しいコードを足すだけで済む形を目指します。

直す前:文字列の switch が3か所にある

送料・表示名・到着日数を、それぞれ別のクラスが文字列の switch で計算しています。

// ShippingFee.java
public class ShippingFee {
    static int of(String method, int price) {
        return switch (method) {
            case "standard" -> price >= 5000 ? 0 : 500;
            case "express" -> 1200;
            default -> throw new IllegalArgumentException("未対応の配送方法: " + method);
        };
    }
}
// ShippingLabel.java・DeliveryDays.java も同じ形の switch(表示名・日数)

仕様変更:配送方法「店舗受け取り(pickup)」を追加する。 まず、送料と表示名は直したが、到着日数(DeliveryDays.java)を直し忘れた場合です。

----- 変更したファイル
 Main.java          | 2 +-
 ShippingFee.java   | 1 +
 ShippingLabel.java | 1 +
 3 files changed, 3 insertions(+), 1 deletion(-)
----- 変更後の実行
通常配送:送料500円、3日
お急ぎ便:送料1200円、1日
Exception in thread "main" java.lang.IllegalArgumentException: 未対応の配送方法: pickup
    at DeliveryDays.of(DeliveryDays.java:6)
    at Main.main(Main.java:6)

コンパイルは通り、実行して初めて失敗しました。 3か所すべてを直すと、4ファイルの修正になりました。

 DeliveryDays.java  | 1 +
 Main.java          | 2 +-
 ShippingFee.java   | 1 +
 ShippingLabel.java | 1 +
 4 files changed, 4 insertions(+), 1 deletion(-)

直した後(1):種類ごとのクラスにする

// ShippingRule.java
public interface ShippingRule {
    String label();
    int fee(int price);
    int days();
}

// Standard.java(Express.java も同じ形)
public class Standard implements ShippingRule {
    public String label() { return "通常配送"; }
    public int fee(int price) { return price >= 5000 ? 0 : 500; }
    public int days() { return 3; }
}

// ShippingRules.java:使える配送方法の一覧(ここだけに登録する)
public class ShippingRules {
    static final List<ShippingRule> ALL = List.of(
            new Standard(),
            new Express());
}

// Main.java:呼び出す側は種類を知らない
for (ShippingRule r : ShippingRules.ALL) {
    System.out.println(r.label() + ":送料" + r.fee(3000) + "円、" + r.days() + "日");
}

同じ仕様変更は、Pickup.java を足して一覧に1行足すだけでした。

// Pickup.java(追加)
public class Pickup implements ShippingRule {
    public String label() { return "店舗受け取り"; }
    public int fee(int price) { return 0; }
    public int days() { return 2; }
}
----- 変更したファイル
 Pickup.java        | 5 +++++
 ShippingRules.java | 3 ++-
 2 files changed, 7 insertions(+), 1 deletion(-)
----- 変更後の実行
通常配送:送料500円、3日
お急ぎ便:送料1200円、1日
店舗受け取り:送料0円、2日

label・fee・days の3つをインターフェースが要求するので、1つでも書き忘れると Pickup.java のコンパイルが通りません。 直す前のような「実行して初めて分かる漏れ」がなくなります。これはデザインパターンの Strategy パターンと同じ形です。

直した後(2):Java 21 の sealed+switch

OCP は「switch を書いてはいけない」という意味ではありません。 sealed(実装できる型を限定する仕組み)と switch のパターンマッチングを使うと、case の書き漏れをコンパイラが見つけます。

public sealed interface Shipping permits Standard, Express {}
public record Standard() implements Shipping {}
public record Express() implements Shipping {}

public class DeliveryDays {
    static int of(Shipping s) {
        return switch (s) {          // default を書かない
            case Standard st -> 3;
            case Express ex -> 1;
        };
    }
}
// ShippingFee.java・ShippingLabel.java も同じ形

Pickup を permits に足し、switch を直さずにコンパイルした結果です。

DeliveryDays.java:3: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^
1 error

javac 21.0.10 は、既定では1件だけ報告しました。直して再コンパイルすると次の1件が出ます。非公開オプション -XDshould-stop.ifError=GENERATE を付けると、3件まとめて報告されました。

DeliveryDays.java:3: error: the switch expression does not cover all possible input values
ShippingFee.java:3: error: the switch expression does not cover all possible input values
ShippingLabel.java:3: error: the switch expression does not cover all possible input values
3 errors

すべて直すと、1ファイル追加+5ファイル修正(permits と3つの switch、デモの Main.java)になりました。

方法 種類を1つ足すとき 漏れの見つかり方 向いている場面
文字列の switch 4ファイル修正 実行時に例外 種類がほぼ増えない小さな処理
インターフェース 1ファイル追加+1ファイル修正 コンパイル時(実装クラス側) 種類がよく増える。外部から種類を足したい
sealed+switch 1ファイル追加+5ファイル修正 コンパイル時(switch ごと) 種類はほぼ固定で、種類をまたぐ処理(計算・表示・集計)がよく増える

インターフェースは「種類の追加」に強く、sealed+switch は「処理の追加」に強い、という向きの違いがあります。変更がどちらの方向に起きやすいかで選びます。sealed の書き方は、関連記事「Java sealedクラスの使い方|permitsとswitchの書き方」で扱っています。

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

リスコフの置換原則は、「親の型で書かれたコードに子クラスを渡しても、正しく動く」ように作る原則です。 継承は「コードの再利用」ではなく、親の約束(契約)を引き継ぐことを意味します。

Liskov は1987年の基調講演で、置換の性質を次のように述べています。

"If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T."

(型 S の各オブジェクト o1 に対して、型 T のオブジェクト o2 があり、T を前提に書かれたすべてのプログラム P で、o2 を o1 に置き換えても P の振る舞いが変わらないなら、S は T のサブタイプである)

1994年の Liskov と Wing の論文では、「型 T のオブジェクトについて証明できる性質は、T のサブタイプ S のオブジェクトについても成り立つべきだ」という形で定式化されています。

直す前:取り消せない子クラスを足す

親クラス Order の約束は「発送前の注文は必ず取り消せる」です。

// Order.java
public class Order {
    final String id;
    String status = "受付";
    Order(String id) { this.id = id; }
    /** 発送前の注文を取り消す。発送前なら必ず成功する。 */
    void cancel() { status = "取消"; }
    public String toString() { return id + ":" + status; }
}

// CancelBatch.java:一括取消(夜間バッチ)
public class CancelBatch {
    static void run(List<Order> orders) {
        for (Order o : orders) o.cancel();
    }
}

// SupportDesk.java:問い合わせ窓口の画面
public class SupportDesk {
    static String cancel(Order o) {
        o.cancel();
        return "取り消しました: " + o.id;
    }
}

仕様変更:ダウンロード商品を追加する。ダウンロード済みは取り消せない。

// DigitalOrder.java(追加)
public class DigitalOrder extends Order {
    DigitalOrder(String id) { super(id); }
    @Override void cancel() {
        throw new IllegalStateException("ダウンロード済みのため取り消せません: " + id);
    }
}

4件の注文の3件目をダウンロード商品にして、一括取消を動かしました。

----- 変更したファイル
 DigitalOrder.java | 6 ++++++
 Main.java         | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)
----- 変更後の実行
バッチ停止: ダウンロード済みのため取り消せません: A-3
[A-1:取消, A-2:取消, A-3:受付, A-4:受付]

3件目で例外が出てバッチが止まり、取り消せるはずの A-4 まで取り消されませんでした。 子クラスが「必ず取り消せる」という親の約束を破ったため、親の型で書かれた既存のコードが壊れました。

利用側に instanceof を足して直すと、こうなります。

for (Order o : orders) {
    if (o instanceof DigitalOrder) continue;   // 子クラスの事情を利用側が知る
    o.cancel();
}
----- 変更したファイル
 CancelBatch.java  | 5 ++++-
 DigitalOrder.java | 6 ++++++
 Main.java         | 2 +-
 SupportDesk.java  | 1 +
 4 files changed, 12 insertions(+), 2 deletions(-)
----- 変更後の実行
[A-1:取消, A-2:取消, A-3:受付, A-4:取消]

動くようにはなりましたが、子クラスを足すたびに、親の型を使っているすべての場所を探して直す形です。これは OCP にも反します。

直した後:約束に「取り消せない場合」を最初から含める

// CancelResult.java
public sealed interface CancelResult {
    record Cancelled(String id) implements CancelResult {}
    record Rejected(String id, String reason) implements CancelResult {}
}

// Order.java
public interface Order {
    String id();
    /** 取消を試みる。取り消せない場合は例外ではなく Rejected を返す。 */
    CancelResult cancel();
}

// CancelBatch.java
public class CancelBatch {
    static void run(List<Order> orders) {
        int ok = 0, ng = 0;
        for (Order o : orders) {
            switch (o.cancel()) {
                case CancelResult.Cancelled c -> ok++;
                case CancelResult.Rejected r -> { ng++; System.out.println("取消不可: " + r.id() + "(" + r.reason() + ")"); }
            }
        }
        System.out.println("取消 " + ok + "件、取消不可 " + ng + "件");
    }
}

同じ仕様変更(DigitalOrder が Rejected を返す)を入れた結果です。

----- 変更したファイル
 DigitalOrder.java | 7 +++++++
 Main.java         | 2 +-
 2 files changed, 8 insertions(+), 1 deletion(-)
----- 変更後の実行
取消不可: A-3(ダウンロード済み)
取消 3件、取消不可 1件
[A-1:取消, A-2:取消, A-3:受付, A-4:取消]

利用側(CancelBatch・SupportDesk)は1行も変えずに、新しい種類を扱えました。 「取り消せないことがある」が約束に入っているので、DigitalOrder は約束を破っていません。

有名な例:正方形と長方形、List.of

static class Rectangle {
    protected int width, height;
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
    int area() { return width * height; }
}
static class Square extends Rectangle {          // 「正方形は長方形の一種」だから継承…
    @Override void setWidth(int w) { width = w; height = w; }
    @Override void setHeight(int h) { width = h; height = h; }
}
static void resizeAndCheck(Rectangle r) {        // Rectangle を前提に書かれたコード
    r.setWidth(5);
    r.setHeight(4);
    System.out.println(r.getClass().getSimpleName() + ": 期待する面積=20, 実際=" + r.area());
}
static void addDefault(List<String> tags) { tags.add("new"); }
Rectangle: 期待する面積=20, 実際=20
Square: 期待する面積=20, 実際=16
ArrayList: OK
List.of: java.lang.UnsupportedOperationException

算数では正方形は長方形の一種ですが、「幅と高さを別々に変えられる」という長方形の約束を守れません。List.add は Javadoc で「オプションの操作(実装によっては UnsupportedOperationException)」と明記されているので約束違反ではありませんが、呼ぶ側が「変更できるリスト」を前提にすると、同じ型でも動きません。 引数で受け取ったコレクションは変更しないか、コピーしてから変更します。継承そのものの書き方は、関連記事「Java継承とは|extends・superの使い方とオーバーライドを図解」で扱っています。

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

インターフェース分離の原則は、「使う側が使わないメソッドに依存させない」原則です。 大きなインターフェースを、使う側の目的ごとに小さく分けます。

直す前:何でも入りのインターフェース

// OrderStore.java
public interface OrderStore {                       // 何でも入りの大きなインターフェース
    List<Order> findByMonth(String month);
    void save(Order order);
    void delete(String id);
    String exportCsv();
}

// ArchiveStore.java:過去分の保管庫(読み取り専用)
public class ArchiveStore implements OrderStore {
    public List<Order> findByMonth(String month) { return List.of(new Order("2025-001", 1200)); }
    public void save(Order order) { throw new UnsupportedOperationException("読み取り専用です"); }
    public void delete(String id) { throw new UnsupportedOperationException("読み取り専用です"); }
    public String exportCsv() { throw new UnsupportedOperationException("対象外です"); }
}

// ほかに DbOrderStore.java(本番用)と FakeOrderStore.java(テスト用)が OrderStore を実装
月次合計: 1200円
実行時に失敗: java.lang.UnsupportedOperationException: 読み取り専用です
id,amount
2026-001,3000

型の上では archive.delete(...) が呼べるのに、実行すると失敗します。

仕様変更:CSV 出力に「区切り文字」の引数を足す。

----- 変更したファイル
 ArchiveStore.java   | 2 +-
 DbOrderStore.java   | 4 ++--
 FakeOrderStore.java | 2 +-
 Main.java           | 2 +-
 OrderStore.java     | 2 +-
 5 files changed, 6 insertions(+), 6 deletions(-)

CSV を出さない読み取り専用の保管庫(ArchiveStore)と、テスト用の偽物(FakeOrderStore)まで、シグネチャを合わせるためだけに直しました。

直した後:使う側の目的ごとに分ける

public interface OrderReader { List<Order> findByMonth(String month); }
public interface OrderWriter { void save(Order order); void delete(String id); }
public interface CsvExporter { String exportCsv(); }

public class DbOrderStore implements OrderReader, OrderWriter, CsvExporter { ... }
public class ArchiveStore implements OrderReader { ... }               // 読み取りだけを実装
public class FakeOrderStore implements OrderReader, OrderWriter { ... }

public class MonthlyReport {                         // 読むだけの処理は OrderReader に依存
    static int total(OrderReader store, String month) {
        return store.findByMonth(month).stream().mapToInt(Order::amount).sum();
    }
}
----- 変更したファイル
 CsvExporter.java  | 2 +-
 DbOrderStore.java | 4 ++--
 Main.java         | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

5ファイルから3ファイルに減り、CSV と関係ないクラスには手が入りませんでした。さらに、読み取り専用のものに delete を書くと、実行する前にコンパイルエラーになります。

Main.java:4: error: cannot find symbol
        archive.delete("2025-001");
               ^
  symbol:   method delete(String)
  location: variable archive of type OrderReader
1 error

失敗を実行時からコンパイル時に前倒しできるのが、分ける効果です。UnsupportedOperationException を見かけたときの読み方は、関連記事「UnsupportedOperationExceptionの直し方|Java」で扱っています。インターフェースと抽象クラスの使い分けは、関連記事「Javaインターフェースと抽象クラスの違い|比較表と使い分け」で扱っています。

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

依存性逆転の原則は、業務ルールのような上位の処理が、メール送信や DB のような具体的な仕組みに直接依存せず、両者がインターフェースに依存するようにする原則です。 インターフェースは使う側(上位)の都合で決めるのがポイントです。

直す前:具体的なクラスを自分で new している

// ReminderService.java:業務ルール「期限3日前を切ったら通知」
public class ReminderService {
    private final SmtpMailer mailer = new SmtpMailer();  // 具体的なクラスを自分で new している
    boolean remind(String to, LocalDate due) {
        if (LocalDate.now().plusDays(3).isAfter(due)) {  // 今日の日付に直接依存
            mailer.send(to, "期限が近づいています: " + due);
            return true;
        }
        return false;
    }
}

業務ルールを確かめたいだけなのに、メールサーバーがないと動きません。

31日後: false
Exception in thread "main" java.lang.IllegalStateException: SMTPサーバー smtp.example.com に接続できません
    at SmtpMailer.send(SmtpMailer.java:3)
    at ReminderService.remind(ReminderService.java:6)
    at Main.main(Main.java:6)

LocalDate.now() に直接依存しているため、結果が実行する日によって変わり、「3日後ちょうど」のような境界も狙って試せません。

仕様変更:通知をメールからチャットに変える。

----- 変更したファイル
 ChatClient.java      | 5 +++++
 ReminderService.java | 4 ++--
 2 files changed, 7 insertions(+), 2 deletions(-)

通知の手段を変えただけなのに、業務ルールのクラス(ReminderService)を書き換えました。

直した後:約束(インターフェース)と時計を外から渡す

// Notifier.java:業務ルール側が決める「約束」
public interface Notifier {
    void send(String to, String body);
}

// ReminderService.java
public class ReminderService {
    private final Notifier notifier;
    private final Clock clock;
    ReminderService(Notifier notifier, Clock clock) {      // 外から渡してもらう
        this.notifier = notifier;
        this.clock = clock;
    }
    boolean remind(String to, LocalDate due) {
        if (LocalDate.now(clock).plusDays(3).isAfter(due)) {
            notifier.send(to, "期限が近づいています: " + due);
            return true;
        }
        return false;
    }
}

// App.java:組み立てる場所(本番の配線)
public class App {
    static ReminderService reminderService() {
        return new ReminderService(new SmtpNotifier(), Clock.systemDefaultZone());
    }
}

// テスト:偽物の通知と固定した時計で、ルールだけを確かめる
List<String> sent = new ArrayList<>();
Notifier fake = (to, body) -> sent.add(to + " / " + body);
Clock today = Clock.fixed(Instant.parse("2026-10-16T00:00:00Z"), ZoneOffset.UTC);
ReminderService s = new ReminderService(fake, today);
check(s.remind("a@example.com", LocalDate.of(2026, 10, 18)), true);   // 2日後
check(s.remind("b@example.com", LocalDate.of(2026, 10, 19)), false);  // 3日後(境界)
check(s.remind("c@example.com", LocalDate.of(2026, 11, 16)), false);  // 31日後
OK expected=true actual=true
OK expected=false actual=false
OK expected=false actual=false
送信: [a@example.com / 期限が近づいています: 2026-10-18]
[メール] ops@example.com / 期限が近づいています: 2026-10-17

メールサーバーも実際の日付も使わずに、境界(3日後ちょうど)まで確かめられました。同じ仕様変更の結果です。

----- 変更したファイル
 App.java          | 2 +-
 ChatNotifier.java | 5 +++++
 2 files changed, 6 insertions(+), 1 deletion(-)
----- 変更後の実行
OK expected=true actual=true
OK expected=false actual=false
OK expected=false actual=false
送信: [a@example.com / 期限が近づいています: 2026-10-18]
[チャット] #reminder / ops@example.com 期限が近づいています: 2026-10-17

ファイル数は直す前と同じ2つですが、業務ルールのクラス(ReminderService)とそのテストには手が入っていません。 変わったのは配線(App.java)の1行と、新しい通知クラスだけです。

依存性の注入(DI)は DIP を実現する手段の1つです。 DIP は「依存の向き」の考え方、DI は「渡し方」と区別すると混乱しません。Spring では、コンストラクタの引数にインターフェースを書くと実装が渡されます。関連記事「Spring DIとは|@Autowiredとコンストラクタインジェクション」で扱っています。テストで偽物を使う考え方は、関連記事「テストダブルとは|スタブ・モック・スパイ・フェイクの違いと使い分け」で扱っています。モックのライブラリは、関連記事「Mockitoの使い方|JUnit 5の書き方早見表とエラー別の直し方」で扱っています。

よくある誤解

誤解 実際
SRP は「1つのことだけをする」 「変更を求める相手が1つ」。Martin は『Clean Architecture』で、「1つのことだけをする」は関数の話で SRP とは別だと書いている
SRP を守るとファイル数が減る 減るとは限らない。この記事の例でも直したファイル数は同じ1つで、影響が及ぶ範囲が減った
OCP は「既存コードを一切修正しない」 一覧への登録や配線など、どこかは直す。予想できる変更の方向に対して閉じる
OCP は「switch を使うな」 sealed+switch は「処理の追加」に強い。向きで選ぶ
LSP は「継承を使うな」 継承するなら親の約束を弱めない・強めない。守れないなら継承しない
ISP は「1インターフェース1メソッド」 分ける単位は使う側の目的。いつも一緒に使うメソッドは同じインターフェースに置く
DIP は DI コンテナを使うこと DIP は依存の向きの原則。コンテナなしでもコンストラクタで渡せば実現できる
SOLID はオブジェクト指向だけのもの 「変わる理由で分ける」「抽象に依存する」は関数型でも使える

やりすぎの害:原則は「変更の痛み」が出てから効かせる

SOLID 原則は、守るほど良くなるものではありません。まだ起きていない変更に備えて抽象化すると、読みにくいコードが増えます。

やりすぎの例 起きること 目安
実装が1つしかないのに、すべてにインターフェースを作る ファイルが倍になり、処理を追うのにジャンプが増える 差し替える予定(テスト用の偽物を含む)があるときに作る
数行のメソッドまで別クラスに分ける 全体の流れが見えにくくなる 変更を求める相手が違うかで判断する
種類が2つしかない分岐をクラス階層にする 分岐のほうが読みやすいことも多い 同じ分岐が3か所以上に現れたら考える
将来のために設定項目・拡張点を先回りで作る 使われない抽象が保守の対象になる 2回目の同じ変更が来たときに抽象化する

この記事の OCP の例でも、sealed 版は種類の追加で6ファイルに触れました。どの設計にも、変更に強い方向と弱い方向があります。 「同じ理由で何度も、複数の場所を直している」と気づいたときが、原則を当てはめるよいタイミングです。

デザインパターン・クリーンアーキテクチャとの関係

原則 関係の深いデザインパターン この記事での形
S Facade(窓口をまとめる) PayCalculator / HourReporter に分割
O Strategy・Template Method・Decorator ShippingRule(Strategy)
L (パターンというより継承の使い方のルール) CancelResult で約束に「できない場合」を含める
I Adapter(必要なインターフェースだけ見せる) OrderReader / OrderWriter / CsvExporter
D Factory・Abstract Factory、DI Notifier と App(組み立てる場所)

SOLID は「良い設計の性質」、デザインパターンは「その性質を実現するよく使われる形」です。GoF のパターン一覧は、関連記事「デザインパターンの使いどころ|GoF23の一覧と実務でよく使う8つ」で扱っています。

クリーンアーキテクチャは、DIP をアプリ全体の層の向きに広げた考え方です。業務ルール(内側)がフレームワーク・DB・画面(外側)に依存せず、外側が内側の決めたインターフェースを実装します。この記事の Notifier(内側が決める約束)と SmtpNotifier(外側の実装)、App(組み立てる場所)は、その最小の形です。層とディレクトリの分け方は、関連記事「クリーンアーキテクチャとは|依存の向きとディレクトリ構成例」で扱っています。同じ考え方の別の形は、関連記事「ヘキサゴナルアーキテクチャ|ポートとアダプターのJava実装例」で扱っています。

コードレビュー用チェックリスト

原則 レビューで見るところ 見つけたら
S 1つのクラスを、別の部署・役割の依頼で直した履歴がないか 依頼元ごとにクラスを分ける
S テストの準備(セットアップ)が長すぎないか 責任が多すぎる。変更の理由で分ける
O 同じ種類の switch / if が3か所以上にないか インターフェースか sealed+switch にする
O default -> throw で種類の漏れを実行時に知る形になっていないか sealed+switch で default を外す
L 利用側に instanceof 子クラス の分岐がないか 約束を見直す。継承をやめる
L 子クラスが親のメソッドを例外だけで上書きしていないか 約束に「できない場合」を含める
I throw new UnsupportedOperationException() だけの実装がないか インターフェースを分ける
I テスト用の偽物に、使わないメソッドが並んでいないか 使う側の目的で分ける
D 業務ルールのクラスで new 外部の仕組み() をしていないか コンストラクタで受け取る
D LocalDate.now()・Instant.now()・乱数を直接呼んでいないか Clock や乱数生成器を受け取る
全体 実装が1つしかないインターフェースが増えすぎていないか 差し替える予定がなければ class のままにする

よくある質問

Q. SOLID原則とは何ですか? A. 単一責任(S)・開放閉鎖(O)・リスコフの置換(L)・インターフェース分離(I)・依存性逆転(D)の5つの設計原則の頭文字です。Robert C. Martin が2000年の論文で整理し、2004年ごろ Michael Feathers が SOLID と名付けました。

Q. SOLID原則をわかりやすく一言でいうと? A. 「仕様変更のときに、直す場所を少なくし、関係ない場所を壊さないための分け方とつなぎ方」です。冒頭の早見表で、5つの1行説明と違反のサインを比べられます。

Q. 5つのうち、どれから意識すればよいですか? A. 単一責任(S)と依存性逆転(D)から始めると、効果が見えやすくなります。S は「どこで分けるか」、D は「どうつなぐか」で、この2つでテストのしやすさが大きく変わります。

Q. 新人研修やレビューでどう使えばよいですか? A. 原則の暗記より、「直す前」のコードに仕様変更を1つ入れて、直したファイル数と壊れた場所を数えてみる方法が理解につながります。この記事の5つの例は、そのまま題材に使えます。レビューでは上のチェックリストを使えます。

Q. AI にコードを書いてもらうときにも役立ちますか? A. 役立ちます。「通知はインターフェースにして、コンストラクタで受け取る形で」「現在時刻は Clock で受け取って」のように設計の方針を具体的に伝えると、テストしやすい形で生成されます。既存コードの改善を依頼する方法は、関連記事「生成AIでリファクタリングするプロンプト集|レガシーコード改善の手順」で扱っています。

まとめ

  • SOLID 原則は、仕様変更の影響を関係ない場所に広げないための5つの設計原則(Robert C. Martin が整理)
  • S:変更を求める相手ごとに分ける。直すファイル数ではなく影響範囲が小さくなる
  • O:種類の追加はクラスの追加で。sealed+switch は処理の追加に強く、漏れをコンパイル時に見つける
  • L:子クラスは親の約束を守る。守れないなら、約束に「できない場合」を含めるか継承しない
  • I:使う側の目的でインターフェースを分けると、無関係なクラスを直さずに済み、誤用はコンパイルエラーになる
  • D:業務ルールは抽象に依存させ、通知・DB・時計は外から渡す。テストのしやすさに直結する
  • 原則は変更の痛みが見えてから当てはめ、やりすぎに気をつける

参考・出典

確認日はいずれも 2026年10月16日です。

  • Robert C. Martin「The Single Responsibility Principle」(2014年5月8日):https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html
  • Robert C. Martin「Solid Relevance」(2020年10月18日):https://blog.cleancoder.com/uncle-bob/2020/10/18/Solid-Relevance.html
  • Robert C. Martin『Clean Architecture: A Craftsman's Guide to Software Structure and Design』(Pearson、2017年。邦訳『Clean Architecture 達人に学ぶソフトウェアの構造と設計』角征典・高木正弘 訳、KADOKAWA)
  • Barbara Liskov「Data Abstraction and Hierarchy」(OOPSLA '87 Addendum、SIGPLAN Notices 23(5)、1988年)
  • Barbara H. Liskov, Jeannette M. Wing「A Behavioral Notion of Subtyping」(ACM TOPLAS 16(6)、1994年)
  • Bertrand Meyer『Object-Oriented Software Construction』(Prentice Hall、1988年)
  • Wikipedia「SOLID」(Martin の2000年の論文と Michael Feathers による命名):https://en.wikipedia.org/wiki/SOLID
  • Java SE 21 API java.util.List(add のオプション操作):https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html
  • JEP 409: Sealed Classes:https://openjdk.org/jeps/409
  • JEP 441: Pattern Matching for switch:https://openjdk.org/jeps/441

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SOLID原則をわかりやすく|早見表とJavaの直す前・後で比較」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり