第26章:「1ユースケース=1トランザクション」実装💾✅
26.1 この章のゴール🎯✨
この章を終えると、こんな状態になれます😊💡
- ✅ **「PlaceOrder(注文確定)」を“1回で完結”**できる(=整合性が壊れにくい)
- ✅ SaveChanges の位置がブレない(毎回ここ!って言える)🎯
- ✅ 「途中で外部I/Oしない」の意味が、コードの形でわかる🧷
26.2 今日の「最新」メモ🗓️✨(2026/01/27時点)
- .NET:.NET 10(LTS)(最新パッチ 10.0.2)(Microsoft)
- EF Core:EF Core 10(LTS)(例:10.0.2)(Microsoft Learn)
- Visual Studio:Visual Studio 2026 18.2.1(2026/01/20 リリース)(Microsoft Learn)
26.3 いきなり結論:これが「1ユースケース=1トランザクション」✅🔒

“1ユースケース=DbContext 1個=SaveChanges 1回” が基本形だよ😊✨
そして、EF Core は SaveChanges 1回の中身を(可能なら)自動でトランザクションに包むよ💾 つまり、SaveChanges が成功すれば全部反映、失敗すれば全部ロールバック✅💥 (Microsoft Learn)
26.4 「トランザクション境界」はどこに置く?🧱
答え:Application Service(ユースケース) に置くのがいちばん安定するよ🎬✨
なぜなら…
- ユースケースは「ユーザーの1行動」👆
- その1行動で「守りたい整合性」をまとめて守る🔒
- “いつ確定するか(SaveChanges)”を決めるのは、ユースケース側が自然🎯
ちなみに DbContext 自体が Unit of Work(短命で、1まとまりの作業) として設計されてるよ🧠✨ (Microsoft Learn)
26.5 まずは完成図(ざっくり)を見よう👀📦
「PlaceOrder」ユースケースの流れはこう👇
[UI/API]
|
v
[Application Service] ← ここが境界🎬🔒
| (Domainを呼ぶ)
v
[Domain: Order Aggregate] ← ルールを守る🌳🔐
|
v
[Infrastructure: EF Core(DbContext)]
|
v
SaveChangesAsync() ← ここで確定💾✅
26.6 PlaceOrder(注文確定)を実装してみよう☕️🧾✨
26.6.1 今回の最低限ルール(カフェ注文)📋🍰
- 注文には 1つ以上の明細が必要🧾
- 数量は 1以上🍰
- 注文確定したら 後から明細を追加できない(不変条件の例)🔐🚫
26.7 コード:Domain(集約側)🌳✨
26.7.1 Value Object(例:お金)💰
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Jpy(decimal amount)
=> amount >= 0 ? new Money(amount, "JPY")
: throw new ArgumentOutOfRangeException(nameof(amount), "金額は0以上だよ💦");
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency) throw new InvalidOperationException("通貨が違うよ💦");
return new Money(a.Amount + b.Amount, a.Currency);
}
public static Money operator *(Money a, int qty)
=> qty >= 0 ? new Money(a.Amount * qty, a.Currency)
: throw new ArgumentOutOfRangeException(nameof(qty));
}
26.7.2 Entity(OrderItem)🧾
public sealed class OrderItem
{
public Guid MenuItemId { get; private set; }
public string Name { get; private set; } = "";
public Money UnitPrice { get; private set; }
public int Quantity { get; private set; }
private OrderItem() { } // EF用
internal OrderItem(Guid menuItemId, string name, Money unitPrice, int quantity)
{
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity), "数量は1以上だよ💦");
MenuItemId = menuItemId;
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("名前が空だよ💦") : name;
UnitPrice = unitPrice;
Quantity = quantity;
}
public Money LineTotal() => UnitPrice * Quantity;
}
26.7.3 Aggregate Root(Order)👑🌳
public enum OrderStatus
{
Draft = 0,
Placed = 1
}
public sealed class Order
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items;
private Order() { } // EF用
private Order(Guid id, Guid customerId)
{
Id = id;
CustomerId = customerId;
Status = OrderStatus.Draft;
}
public static Order Create(Guid customerId)
=> new Order(Guid.NewGuid(), customerId);
public void AddItem(Guid menuItemId, string name, Money unitPrice, int quantity)
{
EnsureDraft();
// 例:同一メニューは合算したい、などのルールはここに寄せる🧠✨
_items.Add(new OrderItem(menuItemId, name, unitPrice, quantity));
}
public void Place()
{
EnsureDraft();
if (_items.Count == 0)
throw new InvalidOperationException("明細が0件の注文は確定できないよ💦");
Status = OrderStatus.Placed;
}
public Money Total()
=> _items.Aggregate(Money.Jpy(0), (acc, x) => acc + x.LineTotal());
private void EnsureDraft()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("確定後の注文は変更できないよ💦");
}
}
ポイント🌟
- 「整合性ルール」は集約に閉じ込める(AddItem / Place の中で守る)🔐
- DB確定(SaveChanges)やトランザクションは Domainは知らない🙅♀️✨
26.8 コード:Infrastructure(EF Core)🧪💾
26.8.1 DbContext(Unit of Work)🧠
DbContext は “1まとまりの作業”向けに短命で使う設計だよ🪄 (Microsoft Learn)
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var order = modelBuilder.Entity<Order>();
order.HasKey(x => x.Id);
// Items は private field をマッピング(Field-backed)✨
order.Metadata.FindNavigation(nameof(Order.Items))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
order.OwnsMany<OrderItem>("_items", item =>
{
item.WithOwner().HasForeignKey("OrderId");
item.Property<Guid>("Id"); // shadow key でもOK
item.HasKey("Id");
item.Property(x => x.Name).HasMaxLength(200);
// Money を Owned として扱う例(UnitPrice)
item.OwnsOne(x => x.UnitPrice, money =>
{
money.Property(m => m.Amount).HasColumnName("UnitPriceAmount");
money.Property(m => m.Currency).HasColumnName("UnitPriceCurrency").HasMaxLength(3);
});
});
}
}
26.8.2 Repository(SaveChangesしないのがコツ)🏪🙅♀️
using Microsoft.EntityFrameworkCore;
public interface IOrderRepository
{
Task AddAsync(Order order, CancellationToken ct);
Task<Order?> FindAsync(Guid orderId, CancellationToken ct);
}
public sealed class EfOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public EfOrderRepository(AppDbContext db) => _db = db;
public Task AddAsync(Order order, CancellationToken ct)
{
_db.Orders.Add(order);
return Task.CompletedTask;
}
public Task<Order?> FindAsync(Guid orderId, CancellationToken ct)
=> _db.Orders.FirstOrDefaultAsync(x => x.Id == orderId, ct);
}
✅ 重要:Repository の中で SaveChanges しない! SaveChanges は ユースケース(アプリ層)の最後に1回🎯✨
26.9 コード:Application(ユースケース)🎬🔒
26.9.1 Command(入力)🧾
public sealed record PlaceOrderCommand(
Guid CustomerId,
IReadOnlyList<PlaceOrderItem> Items
);
public sealed record PlaceOrderItem(
Guid MenuItemId,
string Name,
decimal UnitPriceJpy,
int Quantity
);
26.9.2 Application Service(境界はここ!)🎯
public sealed class PlaceOrderService
{
private readonly AppDbContext _db; // UoW(= DbContext)
private readonly IOrderRepository _orders;
public PlaceOrderService(AppDbContext db, IOrderRepository orders)
{
_db = db;
_orders = orders;
}
public async Task<Guid> HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
if (cmd.Items.Count == 0)
throw new InvalidOperationException("明細が0件だよ💦");
// 1) Domain(集約)を作る🌳
var order = Order.Create(cmd.CustomerId);
foreach (var i in cmd.Items)
{
order.AddItem(
i.MenuItemId,
i.Name,
Money.Jpy(i.UnitPriceJpy),
i.Quantity
);
}
order.Place(); // 不変条件チェック込み🔐✨
// 2) 追加(まだDB確定しない)🧺
await _orders.AddAsync(order, ct);
// 3) ここで一発確定!💾✅
// SaveChanges 1回が “1ユースケース=1トランザクション” の中心
// SaveChanges は既定でトランザクションを張ってくれる(可能なプロバイダの場合)🔒
await _db.SaveChangesAsync(ct); :contentReference[oaicite:6]{index=6}
return order.Id;
}
}
ここが“型”だよ〜😊✨
- Domain:ルールを守る🌳🔐
- Repository:追加/取得だけ🏪
- Application:SaveChangesの位置を固定🎯
26.10 「途中で外部I/Oしない」ってどういうこと?🧷⚠️
26.10.1 なぜダメになりやすいの?😵💫
外部I/O(決済API、メール送信、外部HTTP、メッセージブローカー…)を トランザクションの途中でやると👇
- 外部は成功したのに、DBが失敗 → 二重請求/二重送信の事故💥
- 外部が遅くてトランザクションが長い → ロックが伸びる😱
- リトライ地獄 → どっちが正しい状態かわからなくなる🌀
だから基本はこう👇
- ✅ DBの更新を先に1回で確定(SaveChanges)
- ✅ 外部連携は「確定後」に(または Outbox で後処理)📮✨ ※後半の章(イベント/Outbox)で強化するやつだよ〜📣
26.10.2 NG例(やりがち)🙅♀️
// ❌ 途中で決済APIを叩く → 失敗時に整合性が崩れやすい
await paymentGateway.ChargeAsync(...); // 外部I/O
await _db.SaveChangesAsync(ct);
26.10.3 まずの基本(この章の範囲)✅
この章では **「PlaceOrderは注文確定だけ」**にして、外部I/Oは入れない方針でOK🙆♀️✨ (決済や通知は、後の章で安全にやる)
26.11 よくある事故集(これだけ避けて!)🚑😅
事故①:Repository の中で SaveChanges しちゃう💥
- 呼ぶ側が「いつ確定したか」見えなくなる😵
- ユースケースが複数操作すると SaveChangesが分散しやすい🌀
事故②:SaveChanges を何回も呼ぶ🧨
- 「一部だけ保存された」状態が生まれやすい(明示トランザクション無しだと特に)💥
- 基本は 最後に1回でまとめる🎯✨ (SaveChanges 1回が安全に“全部成功/全部失敗”になりやすいのが強み)(Microsoft Learn)
事故③:DbContext を使い回しすぎる🧟♀️
DbContext は 1ユースケース単位で短く使う前提の設計だよ🧠✨ (Microsoft Learn)
26.12 動作確認のコツ🧪🔍
- PlaceOrder を呼んで、Orders に1件できてる?☕️
- Items がちゃんと紐づいてる?🧾
- 明細0件で Place すると例外になる?💦(=不変条件が守れてる)🔐
26.13 ミニ演習✍️🎀(手を動かすと一気に定着するよ✨)
演習A:SaveChanges を「絶対に1回」に固定して守る🎯
PlaceOrderService.HandleAsyncの中だけで SaveChanges を呼ぶ- Repository には SaveChanges を絶対置かない🙅♀️
演習B:ルール追加(不変条件)🔐
- 「合計金額が0円の注文は禁止」💰🚫
- どこに書く?→ Order.Place() に入れるとキレイ✨
演習C:失敗ケースを作ってロールバック確認💥
- Place の直前でわざと例外を投げる
- DBに何も残らないことを確認(SaveChanges前だから)✅
26.14 AI(Copilot/Codex)活用プロンプト例🤖✨
そのまま貼って使えるやつだよ〜🪄
プロンプト①:ユースケースの骨組みを作る🦴
C# / .NET 10 / EF Core 10 を想定。
「PlaceOrderService」を実装して。
条件:
- 1ユースケース=DbContext1つ=SaveChangesAsync1回
- Repository内ではSaveChangesしない
- DomainのOrder集約に AddItem と Place を用意し、不変条件はDomain側で守る
- 例外メッセージはユーザー向けに短く
プロンプト②:NG例レビュー(地雷除去)💣
このコードで「1ユースケース=1トランザクション」が崩れている箇所を指摘して、
修正案を出して。特に SaveChanges の位置と回数に注目して。
プロンプト③:テスト観点を出す🧪
PlaceOrder のユースケースに対して、最低限必要なテストケースを10個出して。
不変条件違反・境界の責務・SaveChangesの呼び方(1回)を必ず含めて。
26.15 まとめ🌸✨(この章の合言葉)
- ✅ 1ユースケース=DbContext 1個=SaveChanges 1回🎯
- ✅ SaveChanges は(可能なら)自動でトランザクションを張ってくれる🔒(Microsoft Learn)
- ✅ DbContext は 短命=1まとまり作業向け🧠(Microsoft Learn)
- ✅ 外部I/Oは 途中に入れない(安全にやる方法は後で📮📣)
次の章では、「じゃあ明示トランザクションっていつ必要なの?」を、理由つきでスパッと判断できるようにするよ🔒🧠✨