第32章:生成まとめミニ演習:Factory+Builder+Prototypeで「注文作成」完成🎉
ねらい 🎯✨
- 「注文作成」を題材にして、生成パターン3兄弟(Factory / Builder / Prototype)をひとつの流れとして体験するよ〜😊🌸
- 「new が散らばる」「分岐が増える」「初期化が複雑」「テストで差し替えにくい」…この辺をまとめて片付ける感覚をつかむよ🧹🫧
- .NETの定番発想(Builder=StringBuilder/UriBuilder、Prototype=record+with、Factory=生成の押し出し)に寄せて学ぶよ💡✨
到達目標 ✅🌟
この章が終わったら、次ができるようになるよ😊🫶
- 「注文テンプレ」を**Prototype(record + with)**で複製して、用途別にサクッと派生できる🧬✨
- 「注文の組み立て」をBuilderで段階的に安全に作れる🧱🙂
- 「支払い方法の生成」をFactoryに押し出して、呼び出し側の分岐を減らせる🏭✨
- 3つを合体して「注文作成フロー」を完成させ、テストで壊れないのを確認できる🧪🌸
手順 🧭🛠️
0) ゴールの完成イメージを先に見る 👀🎉

この章の最終形は、だいたいこんな流れだよ👇✨
- Prototypeで「基本テンプレ」を複製(用途に応じて少しだけ変更)🧬
- Builderで「注文」を段階的に組み立てる🧱
- Factoryで「支払い手段」を作る🏭
- それらを「注文作成サービス」でつなげる🔗
1) Prototype:注文テンプレを record + with で複製する 🧬✨
Prototypeは「同じ初期状態をコピーして量産」したい時に便利だよ😊 C#では record と with がめちゃ相性いい👍✨
ポイントはこれ👇
- テンプレ側は できるだけ不変(immutable) に寄せる
- with は「シャローコピー」なので、可変コレクションを持たせると事故りやすい⚠️(後で落とし穴で説明するね)
例:テンプレを作るよ🧾✨
public readonly record struct Money(decimal Amount, string Currency);
public enum ShippingSpeed
{
Normal,
Express
}
public sealed record OrderTemplate(
string Currency,
Money DefaultShippingFee,
ShippingSpeed DefaultShippingSpeed,
string DefaultCountry
);
テンプレの複製(Prototype)はこちら👇🧬✨
var baseTemplate = new OrderTemplate(
Currency: "JPY",
DefaultShippingFee: new Money(500m, "JPY"),
DefaultShippingSpeed: ShippingSpeed.Normal,
DefaultCountry: "JP"
);
// 速達テンプレを “複製して差分だけ変更”
var expressTemplate = baseTemplate with
{
DefaultShippingFee = new Money(1200m, "JPY"),
DefaultShippingSpeed = ShippingSpeed.Express
};
「テンプレから派生テンプレを作る」って、地味に便利だよ〜😊🌸 キャンペーン、地域別、VIP向け…みたいに増えても怖くなりにくい🎁✨
2) Builder:注文を段階的に組み立てる 🧱🙂
Builderは「引数が多い」「途中までしか決まってない」「最後にまとめて検証したい」時に強いよ💪✨
ここでは “注文を組み立てる” ために、OrderBuilder を作るよ🛒🧱 (汎用フレームワークじゃなくて、ドメインに寄せた最小BuilderだからOK🙆♀️)
まずは最小のドメインを置くよ👇🍰
public enum PaymentKind
{
CreditCard,
BankTransfer
}
public enum PaymentStatus
{
NotPaid,
Paid,
Failed
}
public sealed record OrderLine(
string Sku,
int Quantity,
Money UnitPrice
);
public sealed record Order(
Guid OrderId,
string Country,
ShippingSpeed ShippingSpeed,
Money ShippingFee,
IReadOnlyList<OrderLine> Lines,
PaymentKind PaymentKind,
PaymentStatus PaymentStatus
)
{
public Money Total =>
new Money(Lines.Sum(x => x.UnitPrice.Amount * x.Quantity) + ShippingFee.Amount, ShippingFee.Currency);
}
次に Builder 本体だよ🧱✨ 「テンプレから開始」「AddLineで追加」「最後にBuildで検証&完成」って流れにするよ😊
public sealed class OrderBuilder
{
private Guid _orderId = Guid.NewGuid();
private string? _country;
private ShippingSpeed? _shippingSpeed;
private Money? _shippingFee;
private readonly List<OrderLine> _lines = new();
private PaymentKind? _paymentKind;
private OrderBuilder() { }
public static OrderBuilder FromTemplate(OrderTemplate template)
{
return new OrderBuilder()
.SetCountry(template.DefaultCountry)
.SetShipping(template.DefaultShippingSpeed, template.DefaultShippingFee);
}
public OrderBuilder SetOrderId(Guid orderId)
{
_orderId = orderId;
return this;
}
public OrderBuilder SetCountry(string country)
{
_country = country;
return this;
}
public OrderBuilder SetShipping(ShippingSpeed speed, Money fee)
{
_shippingSpeed = speed;
_shippingFee = fee;
return this;
}
public OrderBuilder AddLine(string sku, int quantity, Money unitPrice)
{
_lines.Add(new OrderLine(sku, quantity, unitPrice));
return this;
}
public OrderBuilder SetPayment(PaymentKind kind)
{
_paymentKind = kind;
return this;
}
public Order Build()
{
// ✅ 最後にまとめて検証(ここがBuilderの気持ちよさ)
if (string.IsNullOrWhiteSpace(_country))
throw new InvalidOperationException("Country is required.");
if (_shippingSpeed is null || _shippingFee is null)
throw new InvalidOperationException("Shipping is required.");
if (_paymentKind is null)
throw new InvalidOperationException("PaymentKind is required.");
if (_lines.Count == 0)
throw new InvalidOperationException("At least one order line is required.");
// 例:金額の通貨が混ざってないか軽くチェック
if (_lines.Any(x => x.UnitPrice.Currency != _shippingFee.Value.Currency))
throw new InvalidOperationException("Currency mismatch.");
return new Order(
OrderId: _orderId,
Country: _country!,
ShippingSpeed: _shippingSpeed.Value,
ShippingFee: _shippingFee.Value,
Lines: _lines.ToArray(),
PaymentKind: _paymentKind.Value,
PaymentStatus: PaymentStatus.NotPaid
);
}
}
🌸ここで大事な感覚🌸
- 呼び出し側は「順番に積み上げるだけ」
- 検証は「最後に一回」
- コンストラクタ地獄(引数10個…😵💫)になりにくい
3) Factory:支払い手段の生成を押し出す 🏭💳
次は Factory だよ😊✨ 「支払い方法を増やすたびに、呼び出し側のswitchが増える…😇」を止めるやつ!
まずは支払いの契約(インターフェース)👇
public sealed record PaymentResult(bool Success, string? Message = null);
public interface IPaymentMethod
{
PaymentKind Kind { get; }
Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default);
}
実装(例:カード/振込)👇💳🏦 ※ここは学習用に “処理は薄く” でOKだよ🙂
public sealed class CreditCardPayment : IPaymentMethod
{
public PaymentKind Kind => PaymentKind.CreditCard;
public Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default)
=> Task.FromResult(new PaymentResult(true, "Paid by credit card"));
}
public sealed class BankTransferPayment : IPaymentMethod
{
public PaymentKind Kind => PaymentKind.BankTransfer;
public Task<PaymentResult> PayAsync(Order order, CancellationToken ct = default)
=> Task.FromResult(new PaymentResult(true, "Paid by bank transfer"));
}
Factory本体は「分岐をここに閉じ込める」🏭✨ 呼び出し側は “kindを渡すだけ” にするよ😊
public sealed class PaymentMethodFactory
{
private readonly IReadOnlyDictionary<PaymentKind, IPaymentMethod> _map;
public PaymentMethodFactory(IEnumerable<IPaymentMethod> methods)
{
// 追加されてもここで自動的に拾える形にするのが気持ちいい😆✨
_map = methods.ToDictionary(x => x.Kind, x => x);
}
public IPaymentMethod Create(PaymentKind kind)
{
if (_map.TryGetValue(kind, out var method))
return method;
throw new NotSupportedException($"Unsupported payment kind: {kind}");
}
}
これ、地味に “増やしやすさ” が強いよ〜😊🌸 支払い追加の時、呼び出し側を触らずに済む確率が上がる✨
4) 3つを合体:注文作成サービスを作る 🔗🎉
ここがこの章のメインイベント〜!🎆😆
- Prototype:テンプレを複製して使う
- Builder:注文を作る
- Factory:支払い手段を作る
を一箇所でつなぐよ🛒🏭🧱🧬
public sealed class OrderCreationService
{
private readonly PaymentMethodFactory _paymentFactory;
public OrderCreationService(PaymentMethodFactory paymentFactory)
{
_paymentFactory = paymentFactory;
}
public async Task<Order> CreateAsync(
OrderTemplate template,
Action<OrderBuilder> build,
PaymentKind paymentKind,
CancellationToken ct = default)
{
// 🧬 Prototype:テンプレは呼び出し側が “with” で派生させて渡してくる想定
// 🧱 Builder:テンプレからBuilder開始
var builder = OrderBuilder.FromTemplate(template)
.SetPayment(paymentKind);
build(builder); // 呼び出し側の組み立て手順だけを注入
var order = builder.Build();
// 🏭 Factory:支払い手段の生成
var payment = _paymentFactory.Create(paymentKind);
// 支払い実行(学習用なので軽く)
var result = await payment.PayAsync(order, ct);
return order with
{
PaymentStatus = result.Success ? PaymentStatus.Paid : PaymentStatus.Failed
};
}
}
ここでの “気持ちよさ” ポイント😍✨
- 呼び出し側は「テンプレ選ぶ」「Builderで積む」「支払い種類渡す」だけ
- 生成のごちゃごちゃ(複雑初期化・分岐・差し替え)は中に吸い込めた👏✨
5) DIで組み立てる(標準の形で)🧩🔌
Microsoft.Extensions.DependencyInjection の “いつもの形” でやるよ😊✨ Factoryが IEnumerable を受け取る構造と相性いい👍
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// 支払い実装を登録(増えてもOK)
services.AddSingleton<IPaymentMethod, CreditCardPayment>();
services.AddSingleton<IPaymentMethod, BankTransferPayment>();
services.AddSingleton<PaymentMethodFactory>();
services.AddSingleton<OrderCreationService>();
var provider = services.BuildServiceProvider();
var app = provider.GetRequiredService<OrderCreationService>();
6) MSTestで「壊れてない」を確認する 🧪🌸
ここは “成功体験” 大事だよ〜😊✨ 最低限これだけ通そう👇
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class Chapter32Tests
{
[TestMethod]
public async Task CreateAsync_pays_and_marks_paid()
{
// 🧬 Prototype
var baseTemplate = new OrderTemplate(
Currency: "JPY",
DefaultShippingFee: new Money(500m, "JPY"),
DefaultShippingSpeed: ShippingSpeed.Normal,
DefaultCountry: "JP"
);
var expressTemplate = baseTemplate with
{
DefaultShippingFee = new Money(1200m, "JPY"),
DefaultShippingSpeed = ShippingSpeed.Express
};
// 🏭 Factory準備(DIなしの最小で)
var factory = new PaymentMethodFactory(new IPaymentMethod[]
{
new CreditCardPayment(),
new BankTransferPayment()
});
var service = new OrderCreationService(factory);
// 🧱 Builder操作は Action で注入
var order = await service.CreateAsync(
template: expressTemplate,
build: b =>
{
b.AddLine("SKU-001", 2, new Money(300m, "JPY"));
b.AddLine("SKU-XYZ", 1, new Money(1000m, "JPY"));
},
paymentKind: PaymentKind.CreditCard
);
Assert.AreEqual(PaymentStatus.Paid, order.PaymentStatus);
Assert.AreEqual(ShippingSpeed.Express, order.ShippingSpeed);
Assert.AreEqual(1200m, order.ShippingFee.Amount);
Assert.AreEqual(2, order.Lines.Count);
}
[TestMethod]
public void Builder_requires_lines()
{
var template = new OrderTemplate("JPY", new Money(500m, "JPY"), ShippingSpeed.Normal, "JP");
var builder = OrderBuilder.FromTemplate(template)
.SetPayment(PaymentKind.BankTransfer);
Assert.ThrowsException<InvalidOperationException>(() => builder.Build());
}
}
よくある落とし穴 ⚠️😵💫
-
Prototypeに可変Listを持たせて地獄 😇
- with はシャローコピーだよ〜
- テンプレは「不変寄せ」が安全🧊✨(record + IReadOnly〜 が相性いい)
-
Builderが何でも屋(God Builder)になる 🧱💥
- Builderは「組み立て」だけ!
- 割引計算・在庫引当みたいな業務ルールを混ぜないでね🙅♀️💦
-
Factoryが巨大switch博物館になる 🏭🗿
- 今回みたいに「登録した実装から辞書化」だと膨らみにくいよ😊✨
- 追加は “実装を増やすだけ” に寄せよう🎈
-
“パターンっぽい名前” が目的化 😵
- 目的は「変更が怖くない」「テストしやすい」だよ〜🧪🌸
演習 🎓💪✨
次のうち、好きなのを1つでOKだよ😊🌷
- 支払い方法を1つ追加 ➕💳
- PaymentKind に “PayPay” みたいなのを1個足す(名前は自由🎀)
- IPaymentMethod 実装を足して、Factoryが自動で拾えるのを確認✨
- テンプレを3種類作る 🧬🧬🧬
- 通常・速達・海外向け、みたいに with で派生してみてね🌏✈️
- テストで「テンプレが汚れてない」も確認すると最高👍🧪
- Builderに“クーポン”を追加 🎫✨
- ただし、割引計算は “Order” や “サービス側” に寄せてもOK
- Builderは「入力を集める」中心にして、肥大化させない🙂🧱
自己チェック ✅🔍✨
- Prototype:with で派生テンプレを作れて、元テンプレが汚れてない🧬✅
- Builder:Buildでまとめて検証できて、コンストラクタ地獄を避けられてる🧱✅
- Factory:呼び出し側に分岐が散ってなくて、追加がラク🏭✅
- 合体:注文作成フローが1本の流れで読めて、テストで守れてる🛒🧪✅