概要
SOLID 原則は設計の教科書では必ず登場しますが、「原則は知っているけれど、実際のコードにどう落とし込むのか分からない」という声は少なくありません。とくに業務システムの保守現場では、単一責任原則に違反して肥大化したクラスや、新しい種別を追加するたびに if-else を書き足すコードに日常的に遭遇します。この記事では、注文処理という身近な題材を使い、S(単一責任)・O(開放閉鎖)・D(依存性逆転)の3原則を Java コードで実装します。悪い例と良い例を対比しながら、「なぜこの分割にするのか」「どこまで分ければ十分なのか」という現場での判断基準を整理します。Java 8 のクラスベースの実装から Java 17 の record、Java 21 の sealed interface + switch パターンマッチングまで、バージョンごとの書き方の進化も確認します。
使いどころ
注文クラスに永続化・メール通知・PDF生成が混在しているコードを、責任ごとにクラス分割してリファクタリングする
割引種別(学生・会員・VIP)の追加が発生するたびに既存コードを変更せずに済むよう、Strategy パターンで拡張ポイントを設ける
単体テストでデータベース接続なしに業務ロジックを検証するため、Repository を interface 化してモックに差し替える
コード例
import java.util.List;
public class SolidPrinciplesDemo {
// === S: 単一責任原則 ===
// Order は「注文データを表す」責任だけを持つ
record Order(String product, int quantity) {}
// 永続化は Repository の責任
static class OrderRepository {
public void save(Order order) {
System.out.println("DB に保存: " + order.product());
}
}
// 通知は Notification の責任
static class OrderNotification {
public void sendEmail(Order order) {
System.out.println("メール送信: " + order.product());
}
}
// === O: 開放閉鎖原則 ===
// 新しい割引タイプを追加しても DiscountCalculator は変更不要
interface DiscountStrategy {
double apply(double price);
}
static class DiscountCalculator {
public double calculate(DiscountStrategy strategy, double price) {
return strategy.apply(price);
}
}
// === D: 依存性逆転原則 ===
// 具体クラスではなく interface に依存する
interface OrderRepositoryInterface {
void save(Order order);
}
static class OrderService {
private final OrderRepositoryInterface repo;
// コンストラクタ注入: テスト時にモックへ差し替え可能
public OrderService(OrderRepositoryInterface repo) {
this.repo = repo;
}
public void processOrder(Order order) {
repo.save(order);
}
}
public static void main(String[] args) {
// S: 責任ごとに分離されたクラスを使う
var order = new Order("ノートPC", 1);
new OrderRepository().save(order);
new OrderNotification().sendEmail(order);
// O: ラムダ式で戦略を定義(クラス追加不要)
var calc = new DiscountCalculator();
DiscountStrategy student = price -> price * 0.8;
DiscountStrategy member = price -> price * 0.9;
DiscountStrategy vip = price -> price * 0.7;
System.out.println("学生割引: " + calc.calculate(student, 10000));
System.out.println("会員割引: " + calc.calculate(member, 10000));
System.out.println("VIP割引: " + calc.calculate(vip, 10000));
// D: interface 経由でテスト用モックに差し替え
OrderRepositoryInterface prodRepo =
o -> System.out.println("本番DB: " + o.product());
OrderRepositoryInterface testRepo =
o -> System.out.println("[テスト] モック: " + o.product());
new OrderService(prodRepo).processOrder(order);
new OrderService(testRepo).processOrder(order);
}
}Version Coverage
record で Order を不変データクラスとして簡潔に定義できる。DiscountStrategy をラムダ式で実装すると、個別クラスを作らずに済む。
// Java 17: ラムダ式で戦略を定義(クラス不要)
record Order(String product, int quantity) {}
DiscountStrategy studentDiscount = price -> price * 0.8;
DiscountStrategy memberDiscount = price -> price * 0.9;
DiscountStrategy vipDiscount = price -> price * 0.7;
var calc = new DiscountCalculator();
System.out.println(calc.calculate(vipDiscount, 10000));Library Comparison
注意点
単一責任原則を厳密に適用しすぎると、クラスが細分化されすぎてコードの追跡が困難になる。「変更理由が異なるか」を基準に、過度な分割を避ける
開放閉鎖原則のために interface を導入しても、実装が1つしかない場合は過剰設計になりやすい。将来の拡張が見込まれるかどうかを判断材料にする
依存性逆転原則でコンストラクタ注入を使う場合、依存の数が多いクラスは責任過多のサインである。引数が4つを超えたら単一責任原則に立ち返る
SOLID はあくまで指針であり、すべてを満たすことが目的ではない。保守コストと開発速度のバランスを見て適用範囲を決めること
FAQ
L は「親クラスの契約をサブクラスが破らないこと」、I は「使わないメソッドへの依存を強制しないこと」です。まずは S・O・D を押さえれば、L と I は自然と守られるケースが多いです。
YAGNI の観点から、拡張の必要性が見えた時点で interface を抽出するのが現実的です。最初から用意すると未使用の抽象化が残りやすくなります。
コンストラクタ引数で interface を受け取るだけで実現できます。DI コンテナは便利ですが、原則の理解と実践にフレームワークは必須ではありません。