ストラテジーパターン
処理のやり方を差し替え可能な部品として取り出し、呼び出す側を変えずに実行時に選べるようにするデザインパターン
ストラテジーパターンとは
ストラテジーパターンは、処理のやり方をひとまとまりの部品として取り出し、呼び出す側を書き換えずに差し替えられるようにするデザインパターンである。GoF (1994 年の『Design Patterns』) が挙げた 23 パターンのうち、オブジェクト間の連携を扱う振る舞いのパターンとして収録されている。
効き目の出所は、呼び出す側が「何をするか」だけを知り「どうやるか」を知らない状態を作ることにある。やり方の違いが共通の型の内側に閉じるので、種類が増減しても呼び出し側は触らずに済む。逆に、やり方ごとに必要な引数や戻り値の形が違う場面には効かない。共通の型に収まらないものを 1 つの型へ押し込むと、部品の内部で引数を場合分けする実装に戻ってしまう。
問題: 条件分岐の肥大化
分岐そのものが悪いのではない。問題は、割引の種類という別軸の知識が価格計算の関数の中に置かれていて、種類が増えるたびにこの関数を開いて手を入れることになる点にある。
// ❌ 条件分岐が増え続ける
function calculatePrice(type: string, base: number): number {
if (type === 'regular') return base;
if (type === 'premium') return base * 0.9;
if (type === 'vip') return base * 0.8;
if (type === 'employee') return base * 0.5;
// 新しい割引タイプが追加されるたびに分岐が増える
throw new Error(`Unknown type: ${type}`);
}
ストラテジーで解決
やり方を値として持ち、キーで引き当てる形にすると、追加のときに触る場所が表だけになる。第一級関数がある言語では、GoF がクラス階層 (インターフェースと実装クラス) で示した構造はこのように関数の型 1 つへ縮む。名前の付いたインターフェースという手がかりを失う代わりに、記述量は大幅に減る。
type PricingStrategy = (base: number) => number;
const strategies: Record<string, PricingStrategy> = {
regular: (base) => base,
premium: (base) => base * 0.9,
vip: (base) => base * 0.8,
employee: (base) => base * 0.5,
};
function calculatePrice(type: string, base: number): number {
const strategy = strategies[type];
if (!strategy) throw new Error(`Unknown type: ${type}`);
return strategy(base);
}
// 新しい戦略の追加 = オブジェクトにプロパティを追加するだけ
分岐が消えたわけではない点は押さえておきたい。どの部品を使うかを決める判断は残っており、表の引き当てという形へ移っただけである。移った先が 1 か所に集まるので、対応漏れを目で数えられるようになる。これが実際の利得だ。
実用例: ソート戦略
比較関数はこのパターンの最小形である。並べ方を関数として取り出しておくと、どれを使うかの判断を画面の操作や API の入力に委ねられる。
type SortStrategy<T> = (a: T, b: T) => number;
const byName: SortStrategy<User> = (a, b) => a.name.localeCompare(b.name);
const byAge: SortStrategy<User> = (a, b) => a.age - b.age;
const byCreatedAt: SortStrategy<User> = (a, b) => b.createdAt - a.createdAt;
const sorters: Record<string, SortStrategy<User>> = {
name: byName,
age: byAge,
newest: byCreatedAt,
};
// 実行時に切り替え (未知のキーは既定の並びへ倒す)
users.sort(sorters[order] ?? byName);
実用例: 通知戦略
外部サービスの呼び出しも同じ形に収まる。ただし、失敗時の再試行や記録を部品ごとに書き始めると、送り先によって挙動が食い違う。共通の扱いは呼び出す側に置き、部品には送る手段だけを持たせるのが後で困らない。
type NotifyStrategy = (message: string) => Promise<void>;
const notifiers: Record<string, NotifyStrategy> = {
email: async (msg) => { await ses.send(msg); },
slack: async (msg) => { await slack.post(msg); },
sns: async (msg) => { await sns.publish(msg); },
};
// 環境変数で戦略を切り替え (未知の値は既定へ倒す。表に無いキーを引くと undefined の呼び出しで落ちる)
const notify = notifiers[process.env.NOTIFY_TYPE ?? ''] ?? notifiers.email;
await notify('Order completed');
ストラテジー vs if-else
同じ結果を出す 2 つの書き方は、変更が来たときの手数で差が出る。
| 観点 | if-else | ストラテジー |
|---|---|---|
| 追加のとき触る場所 | 処理本体の関数 | 引き当ての表 |
| テスト | 関数を通して全分岐を辿る | 部品を単体で呼べる |
| 既存コードへの影響 | 動いている関数を変更する | 追加だけで済む (開放閉鎖原則に沿う) |
| どれを使うか決める判断 | 呼び出し箇所ごとに散る | 表として 1 か所に集まる |
| 向く条件 | やり方が 2 つで増える見込みが薄い | やり方が増える / 外から選ばせる |
入れるかどうかの判断
やり方が 2 つしかなく増える見込みが薄いなら、分岐のままでよい。差し替えの仕掛けは、読む人に「他にも実装があるはずだ」と探させる手間を課す。効いてくるのは、やり方が 3 つ以上ある / 追加が外部の都合 (取引先 / 決済手段 / 通知先) で決まる / テストで本物の代わりを渡したい、のいずれかに当てはまるときだ。
部品に状態を持たせると差し替えの利点が薄れる。前回の呼び出し結果を内部に溜めると、切り替えた瞬間にその状態が消えるのか残るのかで挙動が変わり、どちらが正しいのか決められなくなる。入力から出力を決めるだけの形に保つのが扱いやすい。
外から渡すのか自分で選ぶのかは、別の論点である。使う部品を引数やコンストラクタで受け取る形にすれば依存性注入になり、選択の判断は組み立て地点へ移る。このパターンが決めるのは差し替えできる形にすることまでで、誰が選ぶかは設計者が別に決める。
この記事は役に立ちましたか?