第16章:Factory Method ③:演習(支払い手段の生成)💳

ねらい 🎯
- 注文の支払い処理で増えがちな if/switch(分岐) を「生成の責務」として外へ押し出す 🙂
- 「支払い方法が増えるたびにサービス本体を直す」状態から卒業する 🌱
- テストで仕様を固定しながら、安全にリファクタする体験をする 🧪✨
到達目標 ✅
- 支払い方法(例:クレカ / 銀行振込 / 代引き)を Factory Method で生成できる
- 支払い方法を1つ追加するときに、既存の支払いサービス(PayAsyncなど)を 触らずに増やせる
- テストが「導入前→導入後」どちらでも通り、効果を説明できる 💡
手順 🧭
0) 今回つくる最小の登場人物 👥
- Order:注文(支払い方法の種類を持つ)
- IPaymentMethod:支払い手段の共通インターフェイス
- PaymentService:注文を受けて支払いを実行する「入口」
- PaymentMethodCreator:支払い手段を作る役(ここが Factory Method の主役)🏭
1) まず「導入前」を作る(分岐がある状態)😅
1-1. ドメイン(Orderなど)📦
namespace Ch16.FactoryMethod.Domain;
public enum PaymentMethodType
{
CreditCard,
BankTransfer,
CashOnDelivery
}
public readonly record struct Money(decimal Amount, string Currency);
public sealed record Order(
string OrderId,
Money Total,
PaymentMethodType PaymentMethod
);
public sealed record PaymentResult(
bool Success,
string MethodName,
string Message
);
1-2. 支払い手段(最小のダミー実装でOK)💳🏦📦
namespace Ch16.FactoryMethod.Payments;
using Ch16.FactoryMethod.Domain;
public interface IPaymentMethod
{
Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default);
}
public sealed class CreditCardPayment : IPaymentMethod
{
public Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default)
=> Task.FromResult(new PaymentResult(true, "CreditCard", "カード決済OK"));
}
public sealed class BankTransferPayment : IPaymentMethod
{
public Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default)
=> Task.FromResult(new PaymentResult(true, "BankTransfer", "振込案内を発行しました"));
}
public sealed class CashOnDeliveryPayment : IPaymentMethod
{
public Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default)
=> Task.FromResult(new PaymentResult(true, "CashOnDelivery", "代引きで発送します"));
}
1-3. 導入前の PaymentService(switchで生成)🔥
namespace Ch16.FactoryMethod.App;
using Ch16.FactoryMethod.Domain;
using Ch16.FactoryMethod.Payments;
public sealed class PaymentService
{
public async Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default)
{
// 😵 支払い方法が増えるたびにここが増える(分岐の温床)
IPaymentMethod method = order.PaymentMethod switch
{
PaymentMethodType.CreditCard => new CreditCardPayment(),
PaymentMethodType.BankTransfer => new BankTransferPayment(),
PaymentMethodType.CashOnDelivery => new CashOnDeliveryPayment(),
_ => throw new NotSupportedException($"Unsupported: {order.PaymentMethod}")
};
return await method.PayAsync(order, ct);
}
}
2) テストで「現状の仕様」を固定する 🧪🔒
ここが超重要だよ〜!このテストが「安全ベルト」になります 🚗💨
using Ch16.FactoryMethod.App;
using Ch16.FactoryMethod.Domain;
namespace Ch16.FactoryMethod.Tests;
[TestClass]
public sealed class PaymentServiceTests
{
[TestMethod]
public async Task PayAsync_CreditCard_ReturnsCreditCard()
{
var sut = new PaymentService();
var order = new Order("O-001", new Money(1200m, "JPY"), PaymentMethodType.CreditCard);
var result = await sut.PayAsync(order);
Assert.IsTrue(result.Success);
Assert.AreEqual("CreditCard", result.MethodName);
}
[TestMethod]
public async Task PayAsync_BankTransfer_ReturnsBankTransfer()
{
var sut = new PaymentService();
var order = new Order("O-002", new Money(5000m, "JPY"), PaymentMethodType.BankTransfer);
var result = await sut.PayAsync(order);
Assert.IsTrue(result.Success);
Assert.AreEqual("BankTransfer", result.MethodName);
}
}
ポイント 💡
- 「どのクラスを new したか」ではなく、「結果がどうなったか」を見てるのがエラい 👍
- これならリファクタしても壊れにくいよ〜 🧁✨
3) Factory Method で「生成の責務」を外へ押し出す 🏭✨
3-1. Creator(生成役)を作る
ここで Factory Method っぽさが出ます! 「外から呼ぶ Create は固定」「中身の生成だけを派生側に任せる」感じだよ 😊
namespace Ch16.FactoryMethod.App;
using Ch16.FactoryMethod.Domain;
using Ch16.FactoryMethod.Payments;
public abstract class PaymentMethodCreator
{
public abstract PaymentMethodType SupportedType { get; }
// 呼び出し側が使う入口(共通)
public IPaymentMethod Create()
=> CreatePaymentMethod(); // ← ここが Factory Method(派生が決める)
// 派生クラスが “何を new するか” だけ決める
protected abstract IPaymentMethod CreatePaymentMethod();
}
3-2. 具体 Creator を支払い方法ごとに作る 💳🏦📦
namespace Ch16.FactoryMethod.App;
using Ch16.FactoryMethod.Domain;
using Ch16.FactoryMethod.Payments;
public sealed class CreditCardPaymentCreator : PaymentMethodCreator
{
public override PaymentMethodType SupportedType => PaymentMethodType.CreditCard;
protected override IPaymentMethod CreatePaymentMethod()
=> new CreditCardPayment();
}
public sealed class BankTransferPaymentCreator : PaymentMethodCreator
{
public override PaymentMethodType SupportedType => PaymentMethodType.BankTransfer;
protected override IPaymentMethod CreatePaymentMethod()
=> new BankTransferPayment();
}
public sealed class CashOnDeliveryPaymentCreator : PaymentMethodCreator
{
public override PaymentMethodType SupportedType => PaymentMethodType.CashOnDelivery;
protected override IPaymentMethod CreatePaymentMethod()
=> new CashOnDeliveryPayment();
}
4) PaymentService から switch を消す(Creatorに任せる)🧹✨
PaymentService は「生成の詳細」を知らない状態にするよ〜 😌
namespace Ch16.FactoryMethod.App;
using Ch16.FactoryMethod.Domain;
public sealed class PaymentService
{
private readonly IReadOnlyDictionary<PaymentMethodType, PaymentMethodCreator> _creators;
public PaymentService(IEnumerable<PaymentMethodCreator> creators)
{
// ここで1回だけ “対応表” を作る(以降は分岐しない)✨
_creators = creators.ToDictionary(x => x.SupportedType);
}
public async Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default)
{
if (!_creators.TryGetValue(order.PaymentMethod, out var creator))
throw new NotSupportedException($"Unsupported: {order.PaymentMethod}");
var method = creator.Create();
return await method.PayAsync(order, ct);
}
}
5) テストを「ほぼ変えずに」通す 🧪🎉
コンストラクタが変わったので、Creator を渡すだけに変更します(テストの意図はそのまま)🙂
using Ch16.FactoryMethod.App;
using Ch16.FactoryMethod.Domain;
namespace Ch16.FactoryMethod.Tests;
[TestClass]
public sealed class PaymentServiceFactoryMethodTests
{
private static PaymentService CreateSut()
{
var creators = new PaymentMethodCreator[]
{
new CreditCardPaymentCreator(),
new BankTransferPaymentCreator(),
new CashOnDeliveryPaymentCreator()
};
return new PaymentService(creators);
}
[TestMethod]
public async Task PayAsync_CreditCard_ReturnsCreditCard()
{
var sut = CreateSut();
var order = new Order("O-001", new Money(1200m, "JPY"), PaymentMethodType.CreditCard);
var result = await sut.PayAsync(order);
Assert.IsTrue(result.Success);
Assert.AreEqual("CreditCard", result.MethodName);
}
[TestMethod]
public async Task PayAsync_BankTransfer_ReturnsBankTransfer()
{
var sut = CreateSut();
var order = new Order("O-002", new Money(5000m, "JPY"), PaymentMethodType.BankTransfer);
var result = await sut.PayAsync(order);
Assert.IsTrue(result.Success);
Assert.AreEqual("BankTransfer", result.MethodName);
}
}
6) (任意)DI で “追加点” を登録に寄せる 🔌✨
アプリ側(起動時)にまとめると、拡張がさらに気持ちいいです ☺️
using Ch16.FactoryMethod.App;
using Ch16.FactoryMethod.Domain;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// Creator 登録(増えたらここに1行足すだけに寄せられる✨)
services.AddSingleton<PaymentMethodCreator, CreditCardPaymentCreator>();
services.AddSingleton<PaymentMethodCreator, BankTransferPaymentCreator>();
services.AddSingleton<PaymentMethodCreator, CashOnDeliveryPaymentCreator>();
services.AddSingleton<PaymentService>();
var provider = services.BuildServiceProvider();
var paymentService = provider.GetRequiredService<PaymentService>();
var order = new Order("O-003", new Money(3000m, "JPY"), PaymentMethodType.CashOnDelivery);
var result = await paymentService.PayAsync(order);
Console.WriteLine($"{result.MethodName}: {result.Message}");
よくある落とし穴 ⚠️😵
-
Creator が「何でも屋」になる 🧟♀️
- Creator は基本「生成だけ」!検証や業務ルールまで詰め込まないでね
-
戻り型が具体型になって効果が薄れる 🫠
- Create の戻りはできるだけ IPaymentMethod(抽象)にする
-
“Factory っぽい名前”が増えすぎて迷子 🌀
- 支払い手段の数=Creatorの数、くらいの素直さでOK(汎用化しない)
-
対応表(Dictionary)構築時に重複キーで落ちる 💥
- 同じ SupportedType を2つ登録しない(テストで検出できると安心)
演習 🏋️♀️✨
演習1:支払い方法を1つ追加してみよう(拡張の気持ちよさ体験)🎊
- PaymentMethodType に 1つ追加(例:コンビニ払い)
- IPaymentMethod 実装を1つ追加
- Creator を1つ追加
- DI登録(またはテスト用配列)に 1行追加
- 既存の PaymentService は 一切変更しない のが合格ライン 💯
演習2:未対応の支払い方法で例外になるテストを追加しよう 🧪⚡
- 例:PaymentMethodType を追加したのに Creator を登録し忘れたケース
- NotSupportedException が投げられることをテストで保証する
演習3:Creatorの責務が肥大化しないかチェックしよう 🔍
- Creator の中に「ログ」「割引計算」「在庫チェック」みたいなのを入れたくなったら黄色信号 🚥
- それらは別の責務(別クラス)へ寄せるのが基本だよ〜 🙂
チェック ✅📌
- PaymentService から switch/if による “支払い方法ごとの new” が消えている
- 支払い方法を追加しても、PaymentService のコードを変更していない
- テストが「支払い手段の追加」「登録漏れ」両方を守っている
- Creator が生成以外の責務を持っていない(薄い!)✨