第73章:Strategy ④:演習(割引ルールを差し替え)💸
ねらい 🎯✨

- 「割引ルール(方針)」がコロコロ変わる現場で、
if/switch地獄を回避する🌀 - Strategy を “入れるだけ” じゃなくて、「入れない判断」「外す判断」までできるようになる🧠✅
- テストで「差し替えできる安心感」を作る🧪🌸
到達目標 🏁😊
- 割引ルールを 3種類以上 用意して、差し替えできる(例:なし/会員10%/クーポン500円)🔁
- “割引の計算” と “どの割引を使うか” を分離できる✂️
- MSTest で 割引が変わっても壊れないテスト を書ける🧪✨(MSTest は公式チュートリアルあり)(Microsoft Learn)
- 「このケースは Strategy いらないかも」が言える👀⚖️
手順 🧭💻
1) まずは「導入前」の素朴実装を作る 🔥😵
最初はわざと switch で割引を選ぶよ〜!(“つらさ”を味わうやつ🙃)
public enum DiscountKind
{
None,
Member10Percent,
Coupon500Yen
}
public sealed class DiscountCalculator_Before
{
public decimal CalculateTotal(decimal subtotal, DiscountKind kind)
{
var discount = kind switch
{
DiscountKind.None => 0m,
DiscountKind.Member10Percent => subtotal * 0.10m,
DiscountKind.Coupon500Yen => 500m,
_ => 0m
};
if (discount > subtotal) discount = subtotal; // 値引きしすぎ防止
return subtotal - discount;
}
}
ここでの観察ポイント👀
- ルールが増えるたびに
switchが太る🍖 - 「期間限定セール」「初回購入」「クーポン併用不可」…って増えたら地獄が見える😇
2) Strategy(方針の差し替え)にする 🔁🧠
今回は C#標準の Func<> を Strategy として使うよ⚡
(Strategy を “関数として渡す” のが一番軽くて強い💪✨)
✅ ルール:割引は「小計 → 割引額」を返す
- “割引後合計” を返すより、割引額を返す方が、合計計算が安定しやすいよ(併用や上限処理も入れやすい)🧩
public sealed class DiscountCalculator
{
private readonly Func<decimal, decimal> _discountStrategy;
public DiscountCalculator(Func<decimal, decimal> discountStrategy)
{
_discountStrategy = discountStrategy;
}
public decimal CalculateTotal(decimal subtotal)
{
var discount = _discountStrategy(subtotal);
if (discount < 0m) discount = 0m;
if (discount > subtotal) discount = subtotal;
return subtotal - discount;
}
}
3) 割引ルール(Strategy)を用意する 💡🎁
public static class DiscountStrategies
{
// 割引なし
public static readonly Func<decimal, decimal> None = subtotal => 0m;
// 会員 10% OFF
public static readonly Func<decimal, decimal> Member10Percent = subtotal => subtotal * 0.10m;
// クーポン 500円 OFF
public static readonly Func<decimal, decimal> Coupon500Yen = subtotal => 500m;
// ついでに:期間限定「3,000円以上なら 300円OFF」
public static readonly Func<decimal, decimal> Over3000Minus300 = subtotal => subtotal >= 3000m ? 300m : 0m;
}
4) 差し替えを体験する(呼び出し側)🔁✨
同じ DiscountCalculator に、方針だけ差し替えて使うよ!
var calc1 = new DiscountCalculator(DiscountStrategies.None);
var total1 = calc1.CalculateTotal(2000m); // 2000
var calc2 = new DiscountCalculator(DiscountStrategies.Member10Percent);
var total2 = calc2.CalculateTotal(2000m); // 1800
var calc3 = new DiscountCalculator(DiscountStrategies.Coupon500Yen);
var total3 = calc3.CalculateTotal(2000m); // 1500
5) テストを書く(差し替えできる喜び🧪🌸)
MSTest は公式にチュートリアルがあるので、それに乗るのが早いよ〜🧪✨(Microsoft Learn)
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class DiscountCalculatorTests
{
[TestMethod]
public void None_NoDiscount()
{
var calc = new DiscountCalculator(DiscountStrategies.None);
Assert.AreEqual(2000m, calc.CalculateTotal(2000m));
}
[TestMethod]
public void Member10Percent_DiscountApplied()
{
var calc = new DiscountCalculator(DiscountStrategies.Member10Percent);
Assert.AreEqual(1800m, calc.CalculateTotal(2000m));
}
[TestMethod]
public void Coupon500Yen_DiscountApplied()
{
var calc = new DiscountCalculator(DiscountStrategies.Coupon500Yen);
Assert.AreEqual(1500m, calc.CalculateTotal(2000m));
}
[TestMethod]
public void DiscountNeverExceedsSubtotal()
{
var calc = new DiscountCalculator(DiscountStrategies.Coupon500Yen);
Assert.AreEqual(0m, calc.CalculateTotal(300m)); // 300円から500円引こうとしても 0円で止まる
}
[TestMethod]
public void Over3000Minus300_Works()
{
var calc = new DiscountCalculator(DiscountStrategies.Over3000Minus300);
Assert.AreEqual(2700m, calc.CalculateTotal(3000m));
Assert.AreEqual(2999m, calc.CalculateTotal(2999m));
}
}
6) 「どの割引を使うか」問題:選択ロジックの置き場 🧠🧩
Strategy を入れても、選ぶところが if/switch 地獄 になったら意味ないよね😵💫
ここは “小さく” 2パターンだけ紹介するよ👇
パターンA:Dictionaryで選ぶ(軽量で強い📦✨)
public sealed class DiscountStrategyResolver
{
private readonly IReadOnlyDictionary<string, Func<decimal, decimal>> _strategies;
public DiscountStrategyResolver()
{
_strategies = new Dictionary<string, Func<decimal, decimal>>
{
["none"] = DiscountStrategies.None,
["member10"] = DiscountStrategies.Member10Percent,
["coupon500"] = DiscountStrategies.Coupon500Yen,
["over3000-300"] = DiscountStrategies.Over3000Minus300
};
}
public Func<decimal, decimal> Resolve(string code)
=> _strategies.TryGetValue(code, out var s) ? s : DiscountStrategies.None;
}
パターンB:(発展)DIの「キー付きサービス」で選ぶ🔑✨
最近の .NET では キー付きサービス が公式に使えるよ(AddKeyedSingleton など)(Microsoft Learn) さらに .NET 10 では AnyKey 周りの互換性変更も入ってるので、雑に使うと例外になる点は注意だよ⚠️(Microsoft Learn) (ここは“沼りやすい”ので、まずは Dictionary で十分おすすめ💖)
よくある落とし穴 🕳️⚠️
- 「戦略の数」が増えすぎて把握不能になる😵 → ルールは “商品価格に関係するもの” だけ、みたいに範囲を決める📌
- 選択条件が別の
if/switch地獄になる🌀 → 「選ぶ責務」をResolverに隔離する(呼び出し側に置かない)🧱 - Strategyに状態を持たせて壊れる(スレッド安全性や使い回し問題)💥 → まずは 純粋関数(Func) でやるのが安全🍀
- “差し替えたい点”が曖昧で導入だけ増える🐣💦 → 「何が変わる?」「どこを固定したい?」を1行で言えるまで待つ📝
ミニ演習(10〜30分)⏱️🌸
演習1:新しい割引を1つ追加 🎁
次から1つ選んで、Strategy 追加&テストも書いてね🧪✨
- 「初回購入 15% OFF」🆕
- 「5,000円以上 5% OFF」🛍️
- 「毎月◯日は 200円 OFF」📅
演習2:割引の“上限”を入れる 🧯
例:割引は最大でも 1,000円まで (大きい注文で割引しすぎ事故を防ぐやつ!)
演習3:AI補助で雛形→人間レビュー🤖👀
OpenAI の Codex系や、Microsoft の Copilot を使うなら、こんな感じで頼むと事故りにくいよ👇
-
✅ 依頼テンプレ(コピペOK)
- 「既存の
DiscountCalculator(Func<decimal, decimal>)は変えない」 - 「新しい割引 Strategy を1つ追加」
- 「MSTestでテストも追加」
- 「抽象化・汎用フレームワーク化は禁止」
- 「既存の
-
✅ レビュー観点
- Strategy が **“1個の理由”**で存在してる?(多機能になってない?)
- テストが 仕様を言葉にできてる?(数字だけ羅列してない?)
- “どれを選ぶか” が呼び出し側に漏れてない?(Resolverに閉じてる?)
自己チェック ✅💖
- 割引ルールを 3種類以上、Calculator本体を変えずに差し替えできた🔁
- テストが通って、割引追加しても既存が壊れない🧪✨
- 「選択の責務」を隔離できた(呼び出し側の
if/switchが太ってない)🧩 - 「この程度なら
switchで十分かも」と “導入しない判断” も言える⚖️🙂
(参考メモ:今のLTSは .NET 10 で、長期サポートの軸として安心感があるよ📌)(devblogs.microsoft.com)