こんにちは、かつコーチです。
今回は実務でよく使われる3つのデザインパターン(Singleton・Factory・Strategy)をJavaコードで扱います。
SOLID原則の続編にあたる内容なので、interfaceと依存性逆転の原則を前提知識として進めます。
デザインパターンを学ぶ意味
なぜパターンを知る必要があるのか
デザインパターンとは、過去の開発者たちが繰り返し遭遇した設計上の課題に対する、再利用可能な解決策のカタログです。
「車輪の再発明を防ぐ」だけでなく、「Factoryパターンを使っている」と言うだけでチーム内の設計意図が一瞬で共有できる、共通言語としての価値もあります。
筆者はレビューで「ここはStrategyパターンにした方がテストしやすい」というコメントを受けたことがありますが、パターン名を知らなければこの指摘の意図を理解するのに余計な時間がかかっていたはずです。
Singletonパターンでインスタンスを一意にする
Singletonの基本実装
Singletonパターンは、あるクラスのインスタンスをアプリケーション全体で1つだけに制限するパターンです。
設定情報やログ管理など「複数存在すると不整合が起きるもの」に使われます。
// ❌Before:スレッドセーフでない実装
class AppConfig {
private static AppConfig instance;
private final Map<String, String> settings = new HashMap<>();
private AppConfig() {}
static AppConfig getInstance() {
if (instance == null) {
instance = new AppConfig(); // 複数スレッドが同時に通過すると複数生成される
}
return instance;
}
}
マルチスレッド環境では、複数のスレッドが同時にif (instance == null)を通過し、インスタンスが2つ以上生成される可能性があります。
// ✅After:enumによるSingleton(Effective Javaで推奨されている実装)
enum AppConfig {
INSTANCE;
private final Map<String, String> settings = new HashMap<>();
void set(String key, String value) {
settings.put(key, value);
}
String get(String key) {
return settings.get(key);
}
}
呼び出し側はAppConfig.INSTANCE.get("timeout")のように使います。enumはJVMがクラスロード時にインスタンス生成をスレッドセーフに保証するため、同期処理を自前で書く必要がありません。
Joshua Bloch氏の『Effective Java』でも、Singletonの実装方法としてenumが推奨されています。
Singletonを使うときの注意点
Singletonはテストのしづらさとグローバルな状態共有という副作用を持つため、乱用は避けるべきパターンとされています。
筆者が業務で使うのは「アプリケーション設定」「コネクションプール」のような、本当に1つしか存在してはいけない対象に限定しています。
Factoryパターンでオブジェクト生成を切り出す
Factory Methodパターンの実装
Factoryパターンは、オブジェクトの生成ロジックを専用のメソッド・クラスに切り出すパターンです。newを呼び出し側に散らばらせず、生成条件をFactory側に集約します。
// ❌Before:呼び出し側が具体クラスとif分岐を知っている
class NotificationService {
void send(String type, String message) {
if (type.equals("EMAIL")) {
new EmailNotifier().send(message);
} else if (type.equals("SMS")) {
new SmsNotifier().send(message);
}
}
}
// ✅After:Factoryに生成ロジックを集約する
interface Notifier {
void send(String message);
}
class EmailNotifier implements Notifier {
public void send(String message) { /* メール送信処理 */ }
}
class SmsNotifier implements Notifier {
public void send(String message) { /* SMS送信処理 */ }
}
class NotifierFactory {
static Notifier create(String type) {
return switch (type) {
case "EMAIL" -> new EmailNotifier();
case "SMS" -> new SmsNotifier();
default -> throw new IllegalArgumentException("未対応の種別: " + type);
};
}
}
class NotificationService {
void send(String type, String message) {
Notifier notifier = NotifierFactory.create(type);
notifier.send(message);
}
}
NotificationServiceは具体的な通知手段のクラス名を一切知らずに済み、新しい通知手段(例:Slack)を追加する際もNotifierFactoryだけを修正すれば足ります。
Java 21のswitch式(アロー構文)を使うと、break忘れによるフォールスルーのバグも防げます。
実装で詰まったポイント:Factoryの肥大化
Factoryクラスに種別を追加し続けた結果、switch文が20行を超えて見通しが悪くなった経験があります。
このケースでは、Map<String, Supplier<Notifier>>に登録する方式に切り替えることで、種別追加のたびにswitchを書き換える必要がなくなりました。
class NotifierFactory {
private static final Map<String, Supplier<Notifier>> REGISTRY = Map.of(
"EMAIL", EmailNotifier::new,
"SMS", SmsNotifier::new
);
static Notifier create(String type) {
Supplier<Notifier> supplier = REGISTRY.get(type);
if (supplier == null) {
throw new IllegalArgumentException("未対応の種別: " + type);
}
return supplier.get();
}
}
これはOCP(開放閉鎖の原則)にも沿った改善で、REGISTRYにエントリを追加するだけで拡張できる形になっています。
Strategyパターンでアルゴリズムを差し替え可能にする
Strategyの基本実装
Strategyパターンは、アルゴリズム(処理の手順)をinterfaceとして切り出し、実行時に差し替え可能にするパターンです。
SOLID原則の記事で扱ったDiscountPolicyの例は、実はStrategyパターンそのものです。
interface SortStrategy {
void sort(int[] data);
}
class BubbleSortStrategy implements SortStrategy {
public void sort(int[] data) {
// バブルソートの実装(データ量が少ない場合向け)
}
}
class QuickSortStrategy implements SortStrategy {
public void sort(int[] data) {
// クイックソートの実装(データ量が多い場合向け)
}
}
class Sorter {
private final SortStrategy strategy;
Sorter(SortStrategy strategy) {
this.strategy = strategy;
}
void execute(int[] data) {
strategy.sort(data);
}
}
呼び出し側はデータ量に応じてnew Sorter(new QuickSortStrategy())のように戦略を選択できます。
ラムダ式で簡潔に書く
Java 8以降の関数型インターフェースの知識があれば、単純なStrategyはクラスを作らずラムダ式で表現できます。
interface PricingStrategy {
double calculate(double basePrice);
}
class PriceCalculator {
private final PricingStrategy strategy;
PriceCalculator(PricingStrategy strategy) {
this.strategy = strategy;
}
double price(double basePrice) {
return strategy.calculate(basePrice);
}
}
// 呼び出し側
PriceCalculator regular = new PriceCalculator(price -> price);
PriceCalculator discounted = new PriceCalculator(price -> price * 0.8);
System.out.println(discounted.price(1000)); // 800.0
わざわざ実装クラスを作らなくても、ラムダ式を渡すだけでStrategyパターンの意図(アルゴリズムの差し替え)を実現できます。
これはStrategyパターンがJavaの関数型インターフェースと相性が良い典型例です。
3つのパターンの使い分け
判断軸の整理
| パターン | 目的 | 使うべき場面 |
|---|---|---|
| Singleton | インスタンスを1つに制限する | 設定情報・コネクションプールなど一意性が求められる対象 |
| Factory | 生成ロジックを集約する | 生成条件が複雑、または生成対象が今後増える見込みがある場合 |
| Strategy | アルゴリズムを差し替え可能にする | 同じ処理に複数のやり方があり、実行時に切り替えたい場合 |
3つとも「変更に強くする」という目的は共通していますが、対象がインスタンス数(Singleton)、生成方法(Factory)、アルゴリズム(Strategy)と異なります。
実務では、FactoryでStrategyの実装クラスを生成するというように、複数パターンを組み合わせて使うことも珍しくありません。
まとめ
この記事のポイント
- SingletonはJVMの保証を活かせる
enum実装が安全で簡潔 - Factoryは生成ロジックを集約し、
Mapベースの登録方式でOCPに沿った拡張ができる - Strategyはアルゴリズムを差し替え可能にし、ラムダ式と組み合わせると簡潔に書ける
- 3つのパターンは目的が異なり、組み合わせて使われることも多い
次に読むべき記事
- SOLID原則入門の記事へ
- 不変オブジェクト設計の記事へ
タグ: Java, 上級者向け, 設計