料金計算、並び替え、認証、再試行――目的は同じでも、状況によって方法を切り替えたい処理は数多くあります。条件分岐だけでも実装できますが、方式ごとのルールが育つにつれて、選択条件と計算手順が絡み合い、変更の影響範囲が見えにくくなります。
Strategy(ストラテジー)は、変化する処理を交換可能な単位として切り出すパターンです。本記事では会員向け価格計算を段階的に改善し、型、安全な選択、テスト、失敗時の設計まで扱います。結論から言えば、Strategyの目的は if をなくすことではありません。「方式を選ぶ責務」と「選ばれた方式を実行する責務」を分けることです。
条件分岐が成長すると何がつらいのか
最初は、会員種別に応じた割引を1つの関数へ書いても十分に読みやすいでしょう。金額は浮動小数点の誤差を避けるため、ここでは円単位の整数として扱います。
type MemberType = "regular" | "premium" | "employee";
function calculatePrice(price: number, memberType: MemberType): number {
if (memberType === "premium") {
return Math.floor(price * 0.8);
}
if (memberType === "employee") {
return Math.floor(price * 0.7);
}
return price;
}
問題は分岐の存在ではなく、各分岐が独立した変更理由を持ち始めることです。プレミアム会員はキャンペーン期間だけ割引率が上がる、社員割引には上限がある、将来の会員種別は外部設定から率を読む、といった要件が入ると、1つの関数が日付、設定、上限、丸め規則まで抱えます。
さらに、同じ会員種別の判定が請求見積もり、注文確定、管理画面へ複製されると危険です。ある画面だけ古い割引率を使う不整合が起きます。方式ごとに単体テストをしたくても、巨大な条件関数を経由しなければなりません。
// 典型的な悪化例:選択条件と個別ルールが同じ場所に増え続ける
function calculatePriceBefore(
price: number,
memberType: MemberType,
now: Date,
): number {
if (memberType === "premium") {
const rate = now.getUTCMonth() === 11 ? 0.75 : 0.8;
return Math.floor(price * rate);
}
if (memberType === "employee") {
return Math.max(Math.floor(price * 0.7), price - 5_000);
}
return price;
}
条件が3つだから即座にパターンが必要、という話ではありません。変化の軸が別々なのに同じ関数へ押し込まれていることが、分離を検討する合図です。
Strategyを正確に定義する
GoFのStrategyは、アルゴリズムの一群を定義し、それぞれをカプセル化して交換可能にする振る舞いパターンです。典型的には、処理を利用するContext、共通契約であるStrategy、個別方式を表すConcrete Strategyの3者で考えます。
JavaScriptではStrategyをclassに限定する必要はありません。入力と出力だけで表せる処理なら、関数がそのまま共通契約になります。Contextは具体的な計算方法を知らず、「渡された関数を呼ぶ」ことだけを知ります。
type PricingContext = Readonly<{
now: Date;
}>;
type PricingStrategy = (price: number, context: PricingContext) => number;
function quote(
price: number,
strategy: PricingStrategy,
context: PricingContext,
): number {
if (!Number.isSafeInteger(price) || price < 0) {
throw new RangeError("price must be a non-negative safe integer");
}
return strategy(price, context);
}
quote がContextに相当します。価格の前提条件は全方式に共通なのでContext側で検証し、割引規則だけをStrategyへ渡しています。どこで検証するかは契約次第ですが、同じ検証を各Strategyへ複製しないことが重要です。
関数Strategyを実装する
各方式を同じ型の関数として実装します。外部の可変状態を暗黙に読むのではなく、日時など結果に影響する値を引数で受け取ると、再現可能なテストを書けます。
const regularPricing: PricingStrategy = (price) => price;
const premiumPricing: PricingStrategy = (price, { now }) => {
const isDecember = now.getUTCMonth() === 11;
const rate = isDecember ? 0.75 : 0.8;
return Math.floor(price * rate);
};
const employeePricing: PricingStrategy = (price) => {
const discounted = Math.floor(price * 0.7);
return Math.max(discounted, price - 5_000);
};
利用側は方式の内部条件を知りません。Strategyを差し替えても、Contextの呼び方は変わりません。
const context = { now: new Date("2026-12-10T00:00:00Z") };
quote(10_000, regularPricing, context); // 10_000
quote(10_000, premiumPricing, context); // 7_500
quote(10_000, employeePricing, context); // 7_000
ここで得られる利点は、コード行数の削減ではありません。プレミアム規則の変更が premiumPricing とそのテストへ閉じ、社員規則へ波及しにくくなることです。また、見積もりと注文確定が同じStrategyを利用すれば、規則の重複も避けられます。
選択ロジックを1か所へ集める
Strategyへ分けても、どれを使うか決める処理は消えません。選択が各画面へ散ると、今度は pricingStrategies[type] の記述と未知の値への対応が複製されます。境界で入力を検証し、選択処理を1か所に置きます。
const pricingStrategies = {
regular: regularPricing,
premium: premiumPricing,
employee: employeePricing,
} satisfies Record<MemberType, PricingStrategy>;
function isMemberType(value: string): value is MemberType {
return Object.hasOwn(pricingStrategies, value);
}
function selectPricingStrategy(value: string): PricingStrategy {
if (!isMemberType(value)) {
throw new Error(`unsupported member type: ${value}`);
}
return pricingStrategies[value];
}
satisfies により、会員種別を追加したのにStrategyを登録し忘れた場合は型検査で気づけます。一方、APIやフォームから来る文字列は実行時には任意の値なので、型注釈だけを信用せず検証します。未知の値を通常料金へ黙ってフォールバックさせるか、エラーにするかは業務要件です。請求金額に関わる例では、誤った金額で処理を続けない方を選びました。
function calculateMemberPrice(
price: number,
rawMemberType: string,
now: Date,
): number {
const strategy = selectPricingStrategy(rawMemberType);
return quote(price, strategy, { now });
}
選択をFactoryの責務として別オブジェクトへ分ける設計もあります。しかし登録表が小さく、生成処理もない段階では、この関数で十分です。パターン同士を組み合わせること自体を目的にしないでください。
テストで契約と個別規則を固定する
Strategyを分けた価値は、方式ごとの境界値を直接検証できる点にあります。Node.js標準の node:test と node:assert を使う例です。
import assert from "node:assert/strict";
import test from "node:test";
test("premium pricing uses the December campaign rate", () => {
const actual = quote(10_001, premiumPricing, {
now: new Date("2026-12-01T00:00:00Z"),
});
assert.equal(actual, 7_500);
});
test("employee discount is capped at 5,000 yen", () => {
const actual = quote(100_000, employeePricing, {
now: new Date("2026-07-10T00:00:00Z"),
});
assert.equal(actual, 95_000);
});
共通契約も検証します。たとえば「負の価格を受け付けない」「結果は0以上の安全な整数」という不変条件を決めたなら、すべてのStrategyに対する共通テストを用意できます。
for (const [name, strategy] of Object.entries(pricingStrategies)) {
test(`${name} returns a non-negative safe integer`, () => {
const actual = quote(12_345, strategy, {
now: new Date("2026-07-10T00:00:00Z"),
});
assert.equal(Number.isSafeInteger(actual), true);
assert.ok(actual >= 0);
});
}
test("quote rejects invalid prices before invoking a strategy", () => {
assert.throws(
() => quote(-1, regularPricing, { now: new Date(0) }),
RangeError,
);
});
実務では丸め規則、通貨、税込・税抜の順序も契約へ含めます。Strategyごとに勝手な単位を返せる設計は交換可能とは言えません。引数と戻り値の型が同じだけでなく、意味と失敗の表現も一致している必要があります。
依存や状態を持つStrategy
関数が設定やサービスを閉じ込める場合は、クロージャで生成できます。たとえば運用中に配布される割引率を、呼び出しごとではなくStrategy生成時に固定します。
function createRatePricing(rate: number): PricingStrategy {
if (!Number.isFinite(rate) || rate < 0 || rate > 1) {
throw new RangeError("rate must be between 0 and 1");
}
return (price) => Math.floor(price * rate);
}
const partnerPricing = createRatePricing(0.85);
複数メソッド、明示的な破棄、内部状態が必要ならinterfaceとclassが読みやすい場合もあります。ただし「デザインパターンだからclass」という選択は不要です。JavaScriptでは関数、オブジェクトリテラル、classのうち、必要な契約を最も小さく表せるものを選べます。
interface ShippingStrategy {
calculate(weightGrams: number): number;
describe(): string;
}
class InternationalShipping implements ShippingStrategy {
constructor(private readonly yenPerKg: number) {}
calculate(weightGrams: number): number {
if (weightGrams <= 0) throw new RangeError("weight must be positive");
return 2_000 + Math.ceil(weightGrams / 1_000) * this.yenPerKg;
}
describe(): string {
return "international shipping";
}
}
状態を持つStrategyを共有するときは並行実行にも注意します。「直前の入力」をinstanceへ保存するような実装は、複数リクエストで上書きされます。計算に必要な値は引数へ寄せ、共有するinstanceは可能な限り不変にすると安全です。
非同期Strategyの失敗を揃える
決済手段や検索バックエンドをStrategyにする場合、戻り値はPromiseになります。すべての方式でキャンセルとエラー契約を揃えないと、Contextが方式ごとの例外処理を再び持つことになります。
type SearchStrategy = (
query: string,
options: { signal: AbortSignal },
) => Promise<readonly string[]>;
const apiSearch: SearchStrategy = async (query, { signal }) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal,
});
if (!response.ok) throw new Error(`search failed: ${response.status}`);
return (await response.json()) as string[];
};
あるStrategyだけ失敗を空配列へ変換し、別のStrategyは例外を投げる設計では、呼び出し側が結果を同じ意味で扱えません。空配列が「該当なし」なのか「障害」なのかを区別する必要があるなら、判別可能なUnion型を共通契約にします。タイムアウトや再試行をStrategyの内側へ持たせるか、外側のDecoratorで共通適用するかも決めておきます。
運用中の切り替えを安全にする
Strategyを設定やfeature flagで選べるようにすると、deployせず方式を切り替えられます。その反面、同じreleaseでも利用者ごと、時刻ごとに結果が変わります。障害調査で再現できるよう、入力へ影響しないstrategy IDと設定versionを記録します。選択理由まで各Strategyへ持たせると計算と配布方針が混ざるため、選択側で観測します。
type SelectedPricing = Readonly<{
id: string;
version: string;
strategy: PricingStrategy;
}>;
function calculateObservedPrice(
price: number,
selected: SelectedPricing,
context: PricingContext,
logger: Pick<Console, "info">,
): number {
const result = quote(price, selected.strategy, context);
logger.info("pricing evaluated", {
strategyId: selected.id,
strategyVersion: selected.version,
});
return result;
}
未知の設定値が来たとき、通常料金へfallbackするか処理を止めるかも業務判断です。表示順のように多少の差を許せる処理ならfallbackが可用性を上げます。請求や権限のように誤った結果が重大なら、黙って別方式を使わずfail closedにする方が安全です。fallback回数をmetricsで監視しなければ、設定ミスが長期間隠れます。
新旧Strategyを段階移行する際は、純粋な計算なら両方を実行して結果差を記録するshadow evaluationが役立ちます。ただし決済、通知、保存のような副作用を持つStrategyを二重実行してはいけません。その場合は記録済み入力を隔離環境でreplayするか、結果を外へ出さないdry-run interfaceを別に設計します。
function comparePricing(
price: number,
current: PricingStrategy,
candidate: PricingStrategy,
context: PricingContext,
): { current: number; candidate: number } {
return {
current: quote(price, current, context),
candidate: quote(price, candidate, context),
};
}
Strategyが交換可能でも、切り替え途中のdata形式まで自動的に互換になるわけではありません。方式が永続dataを読み書きするなら、schema version、移行順序、rollback後の互換性を確認します。「処理を差し替えられる」ことと「無停止で安全に切り替えられる」ことは別の保証です。
NgRxのcreateReducerをStrategy的に読む
NgRxの createReducer と on は、action typeに対応するreducer関数を登録します。利用者はactionごとの状態遷移を個別の関数として渡し、生成されたreducerが実行時のactionに合う処理を選びます。
import { createAction, createReducer, on } from "@ngrx/store";
const increment = createAction("[Counter] Increment");
const reset = createAction("[Counter] Reset");
const counterReducer = createReducer(
{ count: 0 },
on(increment, (state) => ({ count: state.count + 1 })),
on(reset, () => ({ count: 0 })),
);
公式のソースでは、createReducer がaction typeとreducerの対応をMapへ組み立て、返した関数が action.type で処理を検索します。複数の on が同じtypeへ登録された場合は既存reducerと新しいreducerを順に適用する実装も確認できます。つまり「共通の入力と出力を持つ処理を登録し、キーに応じて選ぶ」という構造です。
ただし、NgRx公式は createReducer をGoF Strategyの実装として分類していません。reducerは純粋・同期的な状態遷移という固有の契約を持ち、actionのdispatch機構も含みます。本記事での対応付けは、Strategyの「交換可能な処理と選択の分離」を実在コードから観察するための教育的な類推です。名称が似ている、内部にMapがある、というだけで公式の設計意図を断定してはいけません。
単純な代替案を先に検討する
方式が2つだけで処理が1行、変更も同時に起きるなら、三項演算子や switch の方が全体を一目で追えます。Strategyはファイル、名前、登録、選択という間接層を増やします。
function shippingFee(isExpress: boolean): number {
return isExpress ? 1_000 : 500;
}
ほかにも、値だけが違うなら関数ではなく設定表で十分です。Strategyが向くのは「データの差」ではなく「手順の差」を表したいときです。継承で親classのメソッドを上書きするTemplate Methodも代替ですが、実行時に差し替えにくく、継承階層へ結合します。独立した関数を合成できるJavaScriptでは、まず関数注入を検討すると小さく保てます。
失敗しやすい設計
- Strategyごとに単位、丸め、例外の意味が違うと、型が同じでも交換できません。
- 選択条件がUI、API、ドメインへ散ると、追加時に修正漏れが起きます。
- 共有するStrategyへリクエスト固有の可変状態を保存すると、並行処理が干渉します。
- 外部入力を
as MemberTypeで型キャストすると、未知のキーで実行時エラーになります。 - StrategyがContextの内部を広く知ると、結局は相互依存になります。必要な値だけ渡します。
- すべての小さな分岐をclassへすると、処理より配線を読む時間が増えます。
また、方式の追加が既存コードを絶対に変更しない「開放閉鎖原則」の達成を機械的に目指す必要はありません。型Unionや登録表へ1行追加する変更は、明示的で安全なことがあります。プラグインのように第三者が方式を追加する要件がある場合だけ、動的登録APIと重複キー、解除、初期化順序まで設計します。
使うかどうかの判断基準
Strategyが効果を発揮するのは、同じ目的と契約を持つ手順が複数あり、方式ごとに独立した変更・依存・テストがあるときです。テナント設定、機能フラグ、実行環境などに応じて実行時に方式を切り替える場合や、Contextを変更せず新方式を追加したい場合にも適します。
一方、分岐が短く今後も増えない、違いが単なる数値設定、すべての方式が同時に変更される、共通契約を無理に作ると引数がoptionalだらけになる、といった場合は使わない方が明快です。optional引数の増加は、実際には別の目的の処理を1つのStrategy型へ押し込めている兆候です。
導入前には次を確認します。
- 交換したいのは同じ目的を持つ「手順」か。
- 入力、出力、単位、失敗の意味を共通契約にできるか。
- 選択処理を境界の1か所へ置けるか。
- 各方式を独立してテストする価値があるか。
-
switchや設定表より、変更範囲が本当に小さくなるか。
まとめ
Strategyは条件分岐を敵視するパターンではありません。変化する手順を共通契約の後ろへ分離し、利用する側を個別方式の詳細から守ります。JavaScriptでは関数を第一候補にし、依存や複数操作が必要なときだけobjectやclassへ広げると、過剰設計を避けられます。
実装時の要点は、Strategyの選択場所、実行時入力の検証、戻り値と失敗の意味、共有状態の有無です。方式を増やしやすいこと以上に、方式ごとの規則を局所的に理解し、個別に検証できることが本質的な価値です。
参考資料
- Erich Gammaほか『Design Patterns: Elements of Reusable Object-Oriented Software』Strategy章(Addison-Wesley, 1994)
- NgRx公式:Reducers
- NgRx公式:createReducer API
- NgRx公式ソース:reducer_creator.ts
- Node.js公式:Test runner
- TypeScript公式:satisfies演算子