0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptで学ぶStrategy:変わる処理を安全に差し替える

0
Posted at

料金計算、並び替え、認証、再試行――目的は同じでも、状況によって方法を切り替えたい処理は数多くあります。条件分岐だけでも実装できますが、方式ごとのルールが育つにつれて、選択条件と計算手順が絡み合い、変更の影響範囲が見えにくくなります。

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:testnode: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の createReduceron は、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. 交換したいのは同じ目的を持つ「手順」か。
  2. 入力、出力、単位、失敗の意味を共通契約にできるか。
  3. 選択処理を境界の1か所へ置けるか。
  4. 各方式を独立してテストする価値があるか。
  5. switch や設定表より、変更範囲が本当に小さくなるか。

まとめ

Strategyは条件分岐を敵視するパターンではありません。変化する手順を共通契約の後ろへ分離し、利用する側を個別方式の詳細から守ります。JavaScriptでは関数を第一候補にし、依存や複数操作が必要なときだけobjectやclassへ広げると、過剰設計を避けられます。

実装時の要点は、Strategyの選択場所、実行時入力の検証、戻り値と失敗の意味、共有状態の有無です。方式を増やしやすいこと以上に、方式ごとの規則を局所的に理解し、個別に検証できることが本質的な価値です。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?