第22章:ハンズオン① ミニSagaを作る(OrchestrationでGO)🧑✈️🎮
この章で作るもの(完成イメージ)🎯
「注文を確定する」ために、オーケストレーター(司令塔)が順番に呼び出していくミニSagaを作ります🧩
- ✅ 注文作成(オーケストレーター内部で保存)
- ✅ 決済(Payment API)
- ✅ 在庫引当(Inventory API)
- ✅ どこかで失敗したら、逆順で補償(返金・在庫戻し)🔁
Minimal APIは「依存を最小にしたHTTP API」を作るための設計で、こういうミニマイクロサービス実験にすごく相性いいです🚀 (Microsoft Learn)
1) まずは全体像(紙に描くと超ラク)📝💡
オーケストレーションと補償のループ 🧑✈️🔁
成功フロー😊
- Orchestrator:注文を「作成」(状態:Pending)
- Orchestrator → Payment:チャージ(成功したら状態:PaymentDone)
- Orchestrator → Inventory:引当(成功したら状態:InventoryDone)
- Orchestrator:注文を「確定」(状態:Completed)🎉
失敗フロー😵(例:在庫引当が失敗)
- 注文作成 ✅
- 決済 ✅
- 在庫引当 ❌
- 補償:返金 ✅(+在庫引当してたら戻す)🔁
2) プロジェクト構成(3つのミニAPI)🏗️✨
Orchestrator.Api(司令塔🧑✈️)Payment.Api(決済💳)Inventory.Api(在庫📦)
「dotnet new webapi」は、いまは何も指定しないとMinimal APIのプロジェクトが作られる挙動になっていて、必要なら --use-minimal-apis でも指定できます🧠 (Microsoft Learn)
3) 作成手順(CLIで一気に作る)⚡🛠️
PowerShellでOKです✨(以下そのまま貼って動くやつ)
mkdir MiniSagaHandsOn
cd MiniSagaHandsOn
dotnet new sln -n MiniSagaHandsOn
dotnet new webapi -n Payment.Api --no-https
dotnet new webapi -n Inventory.Api --no-https
dotnet new webapi -n Orchestrator.Api --no-https
dotnet sln add Payment.Api/Payment.Api.csproj
dotnet sln add Inventory.Api/Inventory.Api.csproj
dotnet sln add Orchestrator.Api/Orchestrator.Api.csproj
※ webapi テンプレートには --no-https や --no-openapi などのオプションが用意されています🧩 (Microsoft Learn)
※ 2026の最新版として、.NET 10(LTS)と C# 14 が基準になっています✨ (Microsoft)
4) Payment.Api(決済サービス)💳✨
Payment.Api/Program.cs をまるごと置き換え👇
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
var payments = new ConcurrentDictionary<Guid, PaymentRecord>();
app.MapPost("/payments/charge", (ChargeRequest req) =>
{
// 失敗スイッチ(ハンズオン用)😈
if (req.Fail)
return Results.Problem("決済に失敗しました(わざと)💥", statusCode: 500);
// 超ミニ冪等(SagaIdで二重課金を防ぐ)🛡️
if (payments.TryGetValue(req.SagaId, out var existing))
{
if (existing.Status == PaymentStatus.Charged)
return Results.Ok(new ChargeResponse(existing.SagaId, existing.ReceiptId!, existing.Amount, "already charged (idempotent) 🔁"));
if (existing.Status == PaymentStatus.Refunded)
return Results.Problem("このSagaはすでに返金済みです🙅♀️", statusCode: 409);
}
var receiptId = $"rcpt_{Guid.NewGuid():N}";
var record = new PaymentRecord(req.SagaId, req.OrderId, req.Amount, PaymentStatus.Charged, receiptId, DateTimeOffset.UtcNow);
payments[req.SagaId] = record;
return Results.Ok(new ChargeResponse(req.SagaId, receiptId, req.Amount, "charged ✅"));
});
app.MapPost("/payments/refund", (RefundRequest req) =>
{
if (!payments.TryGetValue(req.SagaId, out var existing))
return Results.Ok(new RefundResponse(req.SagaId, "nothing to refund (idempotent) 🔁"));
if (existing.Status == PaymentStatus.Refunded)
return Results.Ok(new RefundResponse(req.SagaId, "already refunded (idempotent) 🔁"));
payments[req.SagaId] = existing with { Status = PaymentStatus.Refunded, UpdatedAt = DateTimeOffset.UtcNow };
return Results.Ok(new RefundResponse(req.SagaId, $"refunded ✅ reason={req.Reason}"));
});
app.MapGet("/payments/{sagaId:guid}", (Guid sagaId) =>
{
return payments.TryGetValue(sagaId, out var record)
? Results.Ok(record)
: Results.NotFound();
});
app.Run();
enum PaymentStatus { Charged, Refunded }
record ChargeRequest(Guid SagaId, string OrderId, decimal Amount, bool Fail = false);
record ChargeResponse(Guid SagaId, string ReceiptId, decimal Amount, string Note);
record RefundRequest(Guid SagaId, string Reason);
record RefundResponse(Guid SagaId, string Note);
record PaymentRecord(Guid SagaId, string OrderId, decimal Amount, PaymentStatus Status, string? ReceiptId, DateTimeOffset UpdatedAt);
5) Inventory.Api(在庫サービス)📦✨
Inventory.Api/Program.cs をまるごと置き換え👇
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
var reservations = new ConcurrentDictionary<Guid, ReservationRecord>();
app.MapPost("/inventory/reserve", (ReserveRequest req) =>
{
// 失敗スイッチ(ハンズオン用)😈
if (req.Fail)
return Results.Problem("在庫引当に失敗しました(わざと)💥", statusCode: 500);
// 超ミニ冪等(SagaIdで二重引当を防ぐ)🛡️
if (reservations.TryGetValue(req.SagaId, out var existing))
{
if (existing.Status == ReservationStatus.Reserved)
return Results.Ok(new ReserveResponse(req.SagaId, existing.ReservationId!, "already reserved (idempotent) 🔁"));
if (existing.Status == ReservationStatus.Released)
return Results.Problem("このSagaの引当はすでに解放済みです🙅♀️", statusCode: 409);
}
var reservationId = $"rsv_{Guid.NewGuid():N}";
var record = new ReservationRecord(req.SagaId, req.ProductId, req.Qty, ReservationStatus.Reserved, reservationId, DateTimeOffset.UtcNow);
reservations[req.SagaId] = record;
return Results.Ok(new ReserveResponse(req.SagaId, reservationId, "reserved ✅"));
});
app.MapPost("/inventory/release", (ReleaseRequest req) =>
{
if (!reservations.TryGetValue(req.SagaId, out var existing))
return Results.Ok(new ReleaseResponse(req.SagaId, "nothing to release (idempotent) 🔁"));
if (existing.Status == ReservationStatus.Released)
return Results.Ok(new ReleaseResponse(req.SagaId, "already released (idempotent) 🔁"));
reservations[req.SagaId] = existing with { Status = ReservationStatus.Released, UpdatedAt = DateTimeOffset.UtcNow };
return Results.Ok(new ReleaseResponse(req.SagaId, "released ✅"));
});
app.MapGet("/inventory/{sagaId:guid}", (Guid sagaId) =>
{
return reservations.TryGetValue(sagaId, out var record)
? Results.Ok(record)
: Results.NotFound();
});
app.Run();
enum ReservationStatus { Reserved, Released }
record ReserveRequest(Guid SagaId, string ProductId, int Qty, bool Fail = false);
record ReserveResponse(Guid SagaId, string ReservationId, string Note);
record ReleaseRequest(Guid SagaId);
record ReleaseResponse(Guid SagaId, string Note);
record ReservationRecord(Guid SagaId, string ProductId, int Qty, ReservationStatus Status, string? ReservationId, DateTimeOffset UpdatedAt);
6) Orchestrator.Api(司令塔=Saga Orchestrator)🧑✈️🔥
Orchestrator.Api/Program.cs をまるごと置き換え👇
using System.Collections.Concurrent;
using System.Net.Http.Json;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddHttpClient("payment", c => c.BaseAddress = new Uri("http://localhost:7002"));
builder.Services.AddHttpClient("inventory", c => c.BaseAddress = new Uri("http://localhost:7003"));
var app = builder.Build();
app.MapOpenApi();
var sagas = new ConcurrentDictionary<Guid, SagaInstance>();
var orders = new ConcurrentDictionary<string, OrderRecord>();
app.MapPost("/orders/place", async (PlaceOrderRequest req, IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("Orchestrator");
var sagaId = Guid.NewGuid();
var orderId = $"ord_{Guid.NewGuid():N}";
using var _ = logger.BeginScope(new Dictionary<string, object> { ["SagaId"] = sagaId, ["OrderId"] = orderId });
var saga = new SagaInstance(sagaId, orderId);
sagas[sagaId] = saga;
orders[orderId] = new OrderRecord(orderId, OrderStatus.Pending, req.ProductId, req.Qty, req.Amount);
logger.LogInformation("Saga started 🟡");
var executed = new List<SagaStep>();
try
{
// 1) Charge(決済)💳
var paymentClient = httpClientFactory.CreateClient("payment");
var chargeRes = await paymentClient.PostAsJsonAsync("/payments/charge",
new ChargeRequest(sagaId, orderId, req.Amount, req.FailPayment));
chargeRes.EnsureSuccessStatusCode();
executed.Add(SagaStep.PaymentCharged);
// 2) Reserve(在庫)📦
var inventoryClient = httpClientFactory.CreateClient("inventory");
var reserveRes = await inventoryClient.PostAsJsonAsync("/inventory/reserve",
new ReserveRequest(sagaId, req.ProductId, req.Qty, req.FailInventory));
reserveRes.EnsureSuccessStatusCode();
executed.Add(SagaStep.InventoryReserved);
// 3) 完了🎉
sagas[sagaId] = saga with { Status = SagaStatus.Completed, UpdatedAt = DateTimeOffset.UtcNow };
orders[orderId] = orders[orderId] with { Status = OrderStatus.Completed };
logger.LogInformation("Saga completed 🟢");
return Results.Ok(new PlaceOrderResponse(orderId, sagaId, "COMPLETED ✅"));
}
catch (Exception ex)
{
logger.LogWarning(ex, "Saga failed 🔴 start compensation...");
sagas[sagaId] = saga with { Status = SagaStatus.Compensating, UpdatedAt = DateTimeOffset.UtcNow };
// 補償は「逆順」🔁
await CompensateAsync(sagaId, req.ProductId, executed, httpClientFactory, logger);
sagas[sagaId] = sagas[sagaId] with { Status = SagaStatus.Compensated, UpdatedAt = DateTimeOffset.UtcNow };
orders[orderId] = orders[orderId] with { Status = OrderStatus.Cancelled };
return Results.Ok(new PlaceOrderResponse(orderId, sagaId, "COMPENSATED 🔁✅"));
}
});
app.MapGet("/sagas/{sagaId:guid}", (Guid sagaId) =>
{
return sagas.TryGetValue(sagaId, out var saga)
? Results.Ok(saga)
: Results.NotFound();
});
app.MapGet("/orders/{orderId}", (string orderId) =>
{
return orders.TryGetValue(orderId, out var order)
? Results.Ok(order)
: Results.NotFound();
});
app.Run();
static async Task CompensateAsync(
Guid sagaId,
string productId,
List<SagaStep> executed,
IHttpClientFactory httpClientFactory,
ILogger logger)
{
// Inventory release(もし引当まで行ってたら)📦↩️
if (executed.Contains(SagaStep.InventoryReserved))
{
try
{
var inventoryClient = httpClientFactory.CreateClient("inventory");
var releaseRes = await inventoryClient.PostAsJsonAsync("/inventory/release", new ReleaseRequest(sagaId));
releaseRes.EnsureSuccessStatusCode();
logger.LogInformation("Compensated: inventory released ✅");
}
catch (Exception ex)
{
logger.LogError(ex, "Compensation failed: inventory release ❌");
}
}
// Payment refund(もし課金まで行ってたら)💳↩️
if (executed.Contains(SagaStep.PaymentCharged))
{
try
{
var paymentClient = httpClientFactory.CreateClient("payment");
var refundRes = await paymentClient.PostAsJsonAsync("/payments/refund", new RefundRequest(sagaId, "saga compensation"));
refundRes.EnsureSuccessStatusCode();
logger.LogInformation("Compensated: payment refunded ✅");
}
catch (Exception ex)
{
logger.LogError(ex, "Compensation failed: refund ❌");
}
}
}
record PlaceOrderRequest(string ProductId, int Qty, decimal Amount, bool FailPayment = false, bool FailInventory = false);
record PlaceOrderResponse(string OrderId, Guid SagaId, string Result);
record ChargeRequest(Guid SagaId, string OrderId, decimal Amount, bool Fail = false);
record ReserveRequest(Guid SagaId, string ProductId, int Qty, bool Fail = false);
record RefundRequest(Guid SagaId, string Reason);
record ReleaseRequest(Guid SagaId);
enum SagaStatus { Started, Completed, Compensating, Compensated }
enum OrderStatus { Pending, Completed, Cancelled }
enum SagaStep { PaymentCharged, InventoryReserved }
record SagaInstance(Guid SagaId, string OrderId)
{
public SagaStatus Status { get; init; } = SagaStatus.Started;
public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow;
}
record OrderRecord(string OrderId, OrderStatus Status, string ProductId, int Qty, decimal Amount);
7) 3つ同時に起動する(ポート固定)🚦✨
今回はわかりやすく 7001/7002/7003 にします🎯 (Orchestrator=7001 / Payment=7002 / Inventory=7003)
CLIで起動(ターミナル3つ)🪟🪟🪟
それぞれ別ターミナルで👇
Payment
dotnet run --project Payment.Api --urls http://localhost:7002
Inventory
dotnet run --project Inventory.Api --urls http://localhost:7003
Orchestrator
dotnet run --project Orchestrator.Api --urls http://localhost:7001
起動できたら、各APIのOpenAPIが開けます📄✨
http://localhost:7001/openapi/v1.jsonhttp://localhost:7002/openapi/v1.jsonhttp://localhost:7003/openapi/v1.json
8) 動作確認(成功 → 失敗 → 補償)🔁✅💥
PowerShellなら curl.exe を使うと安全です(curl が別物なことがあるので)🙆♀️
8-1) 成功パターン🎉
curl.exe -X POST http://localhost:7001/orders/place ^
-H "Content-Type: application/json" ^
-d "{\"productId\":\"apple\",\"qty\":2,\"amount\":1200,\"failPayment\":false,\"failInventory\":false}"
返り値の SagaId を控えて👇
curl.exe http://localhost:7001/sagas/{SagaId}
COMPLETED ✅ になってたらOK🟢✨
8-2) 在庫で失敗 → 補償が走る(返金)💥🔁
curl.exe -X POST http://localhost:7001/orders/place ^
-H "Content-Type: application/json" ^
-d "{\"productId\":\"apple\",\"qty\":2,\"amount\":1200,\"failPayment\":false,\"failInventory\":true}"
Orchestratorは「決済までは成功」→「在庫で失敗」→「返金補償」になります💳↩️✨
Sagaを見ると COMPENSATED 🔁✅ になってるはず!
8-3) 決済で失敗 → 補償ほぼ無し(在庫に触ってない)💥
curl.exe -X POST http://localhost:7001/orders/place ^
-H "Content-Type: application/json" ^
-d "{\"productId\":\"apple\",\"qty\":2,\"amount\":1200,\"failPayment\":true,\"failInventory\":false}"
決済が最初に落ちるので、補償はほぼ走りません(走る対象が無い)🙆♀️
9) この章のミニ演習(3つ)🧪✨
演習A:ステップを1つ増やす(配送📮)
Shipping.Apiを作って「配送手配」を追加📦➡️🚚- 失敗時の補償は「配送キャンセル」
👉 コツ:補償は“逆操作”じゃなくてもOK(キャンセル・無効化・返金など)🧾✨
演習B:Sagaの実行ログを増やす🧾🔍
- Orchestratorで「どのステップまで成功したか」を
SagaInstanceに保存して、GET /sagas/{id}で見えるようにする✨
演習C:補償の失敗を“見える化”する😵💫➡️👀
- いまは補償が失敗してもログだけで終わってます
CompensationFailuresの配列をSagaに持たせて、APIで返すようにしてみよう📌
10) AI(Copilot / Codex)に頼むと爆速になるプロンプト集🤖💨✨
DTOを一気に整える🧩
「C#のMinimal API用に、Order/Payment/Inventoryのリクエスト/レスポンスDTOを record で整理して。命名は英語、わかりやすさ優先。ついでにJSON例も出して」
補償順序のレビュー🔁
「このSagaの補償は逆順で正しい?漏れてる補償や、二重補償の危険がある箇所を指摘して」
例外の握りつぶし改善🧯
「補償の try/catch を改善したい。失敗の種類(通信/500/タイムアウト)ごとにログを分けて、最低限の情報(SagaId/Step/Reason)を残す形にして」
11) よくあるつまずき(ここだけ見れば復帰できる)🧯💡
- 😵 ポートが合ってない Orchestrator の BaseAddress(7002/7003)を再確認!
- 😵 サービス起動順 Payment/Inventory を起動してから Orchestrator を起動するとスムーズ🙆♀️
- 😵 HTTP 500が返る 今回は「Failスイッチ」でわざと 500 を返してます(正常な教材挙動)😈
12) 章まとめ(この章で身についたこと)🎀✨
- Saga(Orchestration)は「司令塔が順番に呼ぶ」🧑✈️
- 失敗したら「逆順に補償」🔁
- ローカルでも、成功→失敗→補償を再現できる🎮
- Minimal APIでミニサービスを作ると、Sagaの感覚が一気に掴める🚀 (Microsoft Learn)