第20章:集約② Order/OrderLine設計(VOの置き場所)🧾📦💎
この章は「学内カフェ注文アプリ☕️」で、**Order集約を“それっぽく完成”**させる回だよ〜!😆✨ ポイントはこれ👇
- **Order(集約ルート)**が、配下(OrderLine)の整合性をぜんぶ守る🏰✨
- **VO(Money/Quantity/ProductCodeなど)**は「値のまま」中に埋め込む💎
- **OrderLineをEntityにする?VOにする?**を、判断できるようになる🧠🧭
※なお、今の最新環境だと .NET 10 + C# 14 が前提で進めやすいよ(C# 14は .NET 10 SDK で使える)🆕✨ (Microsoft Learn)
1) まず「集約」って、ここが大事!📦✨

集約はひとことで言うと…
**「一緒に正しさを守る範囲(整合性の境界)」**だよ🧸✨
そして鉄則👇
- 集約の外からは 集約ルート(Order)だけ を触る🚪
- 配下の子(OrderLine)を、外から直接いじらせない🙅♀️
- 追加や変更は Orderのメソッド経由でやる✅
MicrosoftのDDDガイダンスでも「集約ルートが子の更新をコントロールする」考え方が明示されてるよ🧠✨ (Microsoft Learn)
2) ここが本題:OrderLineは Entity?それとも VO?🤔🧾
迷うよね〜!でも判断のコツはあるよ✅✨
✅ OrderLineを「Entity」に寄せたいとき
こんな要件があるなら Entity が自然🆔✨
- 明細行を 個別に更新したい(数量だけ変える、とか)🧮
- 明細行に **行ID(LineId)**がある/必要🧷
- 「この行」って指せる(UIで行を選んで編集する等)🖱️
- 将来、行単位でイベント・履歴を残したくなりそう📜
✅ OrderLineを「VO」に寄せたいとき
こういう世界なら VO でも全然OK💎✨
- 明細行はただの「値のセット」で、丸ごと差し替えで十分🔁
- 行にIDはいらない(「この行」より「この内容」)
- 「同じ商品はまとめる」運用で、行の概念が薄い🧾➡️🧾
3) この教材ではこう作るよ!🏗️✨(おすすめ構成)

今回は学習用に分かりやすく、そして実務でも使いやすい👇
- **Order:集約ルート(Entity)**🆔
- **OrderLine:集約の子Entity(ローカルID持ち)**🧾🆔
- Money / Quantity / ProductCode:VO💰📦🔤
理由はシンプル👇 「明細の追加・削除・数量変更・合計計算」を、Orderの責務として自然に置けるからだよ✅✨
4) 実装してみよう!🎮✨(最小で気持ちいい形)
目的:Orderに「明細追加」「合計計算」を持たせる➕💰
4-1) VO(値)たち:ID / ProductCode(例)💎
(Money/Quantityは前の章で作った想定でOKだよ〜!😆)
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
public readonly record struct OrderLineId(Guid Value)
{
public static OrderLineId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString();
}
public sealed record ProductCode
{
public string Value { get; }
public ProductCode(string value)
{
value = (value ?? "").Trim();
if (value.Length is < 1 or > 20)
throw new ArgumentException("商品コードは1〜20文字だよ!🧾");
Value = value;
}
public override string ToString() => Value;
}
record structは 軽いIDに相性いいよ🧷✨(値として扱える)ProductCodeは 文字列をそのまま使わないのが大事!事故が減る😇💕
4-2) OrderLine(子Entity)🧾🆔
public sealed class OrderLine
{
public OrderLineId Id { get; }
public ProductCode ProductCode { get; }
public Quantity Quantity { get; private set; }
public Money UnitPrice { get; }
public Money Subtotal => UnitPrice * Quantity; // MoneyとQuantityに演算を用意してる想定✨
internal OrderLine(OrderLineId id, ProductCode productCode, Quantity quantity, Money unitPrice)
{
Id = id;
ProductCode = productCode;
Quantity = quantity;
UnitPrice = unitPrice;
}
internal void ChangeQuantity(Quantity newQuantity)
{
Quantity = newQuantity;
}
internal void Increase(Quantity addQuantity)
{
Quantity = Quantity + addQuantity;
}
}
ポイント💡
internalにして、Order以外から勝手に作れない/変えられないようにするのがコツだよ🔒✨- 「OrderLineを直接newできる」ようにすると、集約の境界が崩れやすい😱
4-3) Order(集約ルート)🏰✨
public enum OrderStatus
{
Draft,
Confirmed,
Canceled
}
public sealed class Order
{
private readonly List<OrderLine> _lines = new();
public OrderId Id { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public Order(OrderId id)
{
Id = id;
}
public void AddLine(ProductCode productCode, Quantity quantity, Money unitPrice)
{
EnsureEditable();
// 同じ商品は「行を増やさず数量を足す」運用にしてみる🧾✨
var existing = _lines.FirstOrDefault(x => x.ProductCode == productCode && x.UnitPrice == unitPrice);
if (existing is not null)
{
existing.Increase(quantity);
return;
}
var line = new OrderLine(OrderLineId.New(), productCode, quantity, unitPrice);
_lines.Add(line);
}
public void ChangeLineQuantity(OrderLineId lineId, Quantity newQuantity)
{
EnsureEditable();
var line = _lines.FirstOrDefault(x => x.Id == lineId)
?? throw new InvalidOperationException("その明細行が見つからないよ😢");
line.ChangeQuantity(newQuantity);
}
public void RemoveLine(OrderLineId lineId)
{
EnsureEditable();
var removed = _lines.RemoveAll(x => x.Id == lineId);
if (removed == 0)
throw new InvalidOperationException("その明細行が見つからないよ😢");
}
public Money Total()
{
// DraftでもConfirmedでも「合計」は出せる想定💰✨
return _lines.Aggregate(Money.Zero(), (acc, line) => acc + line.Subtotal);
}
public void Confirm()
{
EnsureEditable();
if (_lines.Count == 0)
throw new InvalidOperationException("明細が0件だと確定できないよ🙅♀️");
Status = OrderStatus.Confirmed;
}
public void Cancel()
{
if (Status == OrderStatus.Canceled) return;
if (Status == OrderStatus.Confirmed)
throw new InvalidOperationException("確定後キャンセル不可、みたいなルールならここで止めるよ🚫");
Status = OrderStatus.Canceled;
}
private void EnsureEditable()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("確定/キャンセル後は編集できないよ🔒");
}
}
ここが「集約っぽさ」爆発ポイント🔥
- Orderが全部ルールを持つ(編集可能か、0件確定禁止、など)✅
- OrderLineは Orderの内側の都合で動く子👶✨
- 外からは
Linesを読むだけ(いじらせない)🔒
5) ミニ演習(10〜20分)⏱️✨
演習A:合計計算をテストしよう🧪💰
- 2行追加して
Total()が期待通りか確認✅ - 同じ商品を2回
AddLineしたとき、行が増えず数量が増えるのを確認✅
演習B:ルールを1個だけ追加しよう🧠✨
おすすめ👇(どれか1つでOK!)
- 明細は最大10行まで📏
- Quantityの上限(例:1〜99)📦
- Confirmしたら
AddLineできないのをテストで確認🔒🧪
6) テスト例(xUnitの雰囲気)🧪✨
public class OrderTests
{
[Fact]
public void AddLine_SameProductAndPrice_MergesQuantity()
{
var order = new Order(OrderId.New());
order.AddLine(new ProductCode("COFFEE"), Quantity.Of(1), Money.Jpy(300));
order.AddLine(new ProductCode("COFFEE"), Quantity.Of(2), Money.Jpy(300));
order.Lines.Count.Should().Be(1);
order.Lines[0].Quantity.Should().Be(Quantity.Of(3));
order.Total().Should().Be(Money.Jpy(900));
}
[Fact]
public void Confirm_EmptyLines_Throws()
{
var order = new Order(OrderId.New());
Assert.Throws<InvalidOperationException>(() => order.Confirm());
}
}
※ Quantity.Of, Money.Jpy, Money.Zero は前章で作った想定だよ〜!(なければ自作でOK)💎✨
7) AI活用(Copilot / Codex)🤖✨:この章で強い使い方
✅ その1:テストケース案を量産してもらう🧪
こんな感じで投げると便利👇
- 「OrderのAddLine/RemoveLine/Confirmの境界値テスト案を20個出して」
- 「StatusがDraft/Confirmed/Canceledのとき、許可/禁止の表を作って」📊
✅ その2:レビュー観点をAIに出させる✅
- 「このOrder集約コード、集約境界が破れてる箇所ない?」
- 「VOっぽいのにミュータブルになってる部分ない?」😱
AIは速いけど、最後の判断(ルール決め)は人間がやるのが大事だよ🫶✨
8) ありがちな落とし穴(超重要)⚠️😵💫
public set;を開けて、外からOrderLineを改造される🔓➡️崩壊List<OrderLine>をそのまま公開して、勝手にAddされる📦➡️崩壊- 「OrderLineを外でnewして渡す」設計にして、境界があいまいになる🌀
- Money/QuantityをミュータブルにしてHash系で事故る(前章のやつ!)😱
9) まとめ(この章でできるようになったこと)🎉✨
- 集約ルート(Order)が、配下(OrderLine)の整合性を守る感覚がつかめた🏰
- OrderLineを Entity/VOどっちにするかの判断軸ができた🧭
AddLineとTotalを「ドメインっぽく」実装できた➕💰💕
次の第21章は、いよいよ 判断チェックリストを作って「迷ったら戻れる軸」を完成させるよ✅🧠✨