第14章:実務っぽい形へ②(Handler化で責務を固定)👩🍳📨
〜「Endpoint/Controllerは薄く!」「1機能=1ハンドラ!」で、崩れないCQSへ〜
0. この章でできるようになること🎯💗
- 「Controller / Minimal API の中でやる仕事」をスッキリ最小化できる🧼✨
- 「1つの機能=1つのHandler」で、責務(やること)がブレなくなる🎯
- 規模が大きくなっても、CQSが混ざりにくい構造にできる🧱
- 「依存の向き(Dependency Ruleの入口)」を、ふわっと体感できる🧭✨
ちなみに今の最新版は .NET 10(LTS)・C# 14 だよ〜!🆕✨(2025/11にリリース) (Microsoft) Visual Studio 2026 で .NET 10 SDK が入る流れも案内されてるよ🛠️✨ (Microsoft Learn)
1. なんでHandler化するの?🤔💭

第13章で「Command/Queryオブジェクト化」したら、引数地獄は減ったよね👍✨ でも実務で増えてくるのがコレ👇😇💥
- Endpoint/Controllerが育って巨大化する(バリデーション、権限、ログ、DB、変換…全部入り)🍱
- 「どこに何が書いてあるか分からない」→ 修正が怖い😱
- CQSがいつの間にか混ざる(Query内で更新、Command内で参照しすぎ)🌀
そこで登場するのが Handler!👩🍳✨ 「料理(業務処理)はキッチン(Handler)でやる」 「配膳(HTTPの受け渡し)はホール(Endpoint/Controller)でやる」 …みたいに、役割を固定できるのが強いんだよね🍽️💗
2. Handlerの役割を1行で言うと?✍️✨
**「1つのユースケース(機能)を、最後まで責任持って実行する人」**👩🍳🎯
例:ToDoならこんな感じ👇
- ✅ CreateTodoHandler(作成)
- ✅ CompleteTodoHandler(完了)
- 🔍 GetTodosHandler(一覧取得)
- 🔍 GetTodoByIdHandler(詳細取得)
この「1機能=1ハンドラ」が、設計を崩れにくくするコツだよ🧱✨
3. Endpoint/Controllerは“薄く”する🧼✨
✅ Endpoint/Controller側の仕事(ここだけ!)
- 受け取る(DTOにバインド)📥
- ルーティング(URLとメソッド)🛣️
- 認可・認証(必要なら)🔐
- Handler呼ぶ📞
- 結果をHTTPレスポンスにする📤
✅ Handler側の仕事(料理担当🍳)
- 入力チェック(業務ルール)📏
- Repository呼ぶ(保存/取得)🗄️
- 外部サービス(メール等)📨
- 「成功/失敗」を整形して返す🎁
4. 依存の向き(Dependency Rule)の“入口”🧭✨
超ざっくり図でいくね👇(矢印が依存の向きだよ〜)
外側(変わりやすい)
- Presentation(API / Controller / Endpoint) ↓
- Application(Handlers / UseCase) ↓
- Domain(Entity / ValueObject / ルール) 内側(守りたい)
ポイントはこれ👇💡
- DBは「インターフェース(Repository)」越しに触るのが基本🧤✨
5. ミニ実装:ToDoをHandler化してみよう📝🍰
ここでは Minimal API を例にするね!(Controller版の形も最後に出すよ👍) そして、今の .NET 10 だと Minimal API の バリデーションサポートも入ってて、Endpointをさらに薄くしやすいよ✨ (Microsoft Learn)
5-1. フォルダ構成例🗂️✨(迷ったらこれ)
-
Presentation/(Program.cs, Endpoints) -
Application/Commands/Queries/(第13章のDTOたち)Handlers/(今回の主役👑)Abstractions/(Repositoryなどのインターフェース)
-
Domain/(Todoなどのドメイン) -
Infrastructure/(Repository実装・DBなど)
「RepositoryのinterfaceはApplication側」「実装はInfrastructure側」って分けると、依存の向きが守りやすいよ🧭✨
5-2. Domain:ToDo(めちゃシンプル版)🧩
namespace Domain;
public sealed class Todo
{
public Guid Id { get; }
public string Title { get; private set; }
public bool IsCompleted { get; private set; }
public DateTimeOffset CreatedAt { get; }
public Todo(Guid id, string title, DateTimeOffset createdAt)
{
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Title is required.");
Id = id;
Title = title.Trim();
CreatedAt = createdAt;
}
public void Complete()
{
IsCompleted = true;
}
}
5-3. Application:Repositoryのinterface(依存の向きの要✨)
using Domain;
namespace Application.Abstractions;
public interface ITodoRepository
{
Task AddAsync(Todo todo, CancellationToken ct);
Task<Todo?> FindByIdAsync(Guid id, CancellationToken ct);
Task<IReadOnlyList<Todo>> ListAsync(CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
5-4. Application:Command/Query DTO(第13章の復習)📦
namespace Application.Commands;
public sealed record CreateTodoCommand(string Title);
public sealed record CompleteTodoCommand(Guid Id);
namespace Application.Queries;
public sealed record GetTodosQuery();
public sealed record GetTodoByIdQuery(Guid Id);
5-5. Application:Result(超ミニ)🎁
「Commandは基本“変更”だから、成功/失敗をResultで返す」ってやると扱いやすいよ✨
namespace Application;
public sealed record Error(string Code, string Message);
public sealed class Result
{
public bool IsSuccess { get; }
public Error? Error { get; }
private Result(bool isSuccess, Error? error)
=> (IsSuccess, Error) = (isSuccess, error);
public static Result Ok() => new(true, null);
public static Result Fail(string code, string message) => new(false, new Error(code, message));
}
public sealed class Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public Error? Error { get; }
private Result(bool isSuccess, T? value, Error? error)
=> (IsSuccess, Value, Error) = (isSuccess, value, error);
public static Result<T> Ok(T value) => new(true, value, null);
public static Result<T> Fail(string code, string message) => new(false, default, new Error(code, message));
}
6. いよいよ主役:Handlerを作る👩🍳🔥
6-1. Command Handler:Create(作成)✅
using Application.Abstractions;
using Domain;
namespace Application.Handlers;
public sealed class CreateTodoHandler
{
private readonly ITodoRepository _repo;
private readonly IClock _clock;
public CreateTodoHandler(ITodoRepository repo, IClock clock)
=> (_repo, _clock) = (repo, clock);
public async Task<Result<Guid>> HandleAsync(Commands.CreateTodoCommand command, CancellationToken ct)
{
// 業務ルールの入力チェック(Endpointから追い出す!)✨
if (string.IsNullOrWhiteSpace(command.Title))
return Result<Guid>.Fail("validation.title_required", "タイトルを入力してね😊");
var todo = new Todo(Guid.NewGuid(), command.Title, _clock.Now);
await _repo.AddAsync(todo, ct);
await _repo.SaveChangesAsync(ct);
return Result<Guid>.Ok(todo.Id);
}
}
Clockもinterfaceにしちゃうと、テストで時刻固定できて便利だよ🕰️✨
namespace Application.Abstractions;
public interface IClock
{
DateTimeOffset Now { get; }
}
6-2. Command Handler:Complete(完了)✅
using Application.Abstractions;
namespace Application.Handlers;
public sealed class CompleteTodoHandler
{
private readonly ITodoRepository _repo;
public CompleteTodoHandler(ITodoRepository repo) => _repo = repo;
public async Task<Result> HandleAsync(Commands.CompleteTodoCommand command, CancellationToken ct)
{
var todo = await _repo.FindByIdAsync(command.Id, ct);
if (todo is null)
return Result.Fail("todo.not_found", "そのToDo、見つからなかったよ🥲");
todo.Complete();
await _repo.SaveChangesAsync(ct);
return Result.Ok();
}
}
6-3. Query Handler:List(一覧)🔍
Queryは「参照」だから、戻り値は普通にDTO返してOK👌✨
using Application.Abstractions;
namespace Application.Handlers;
public sealed record TodoDto(Guid Id, string Title, bool IsCompleted, DateTimeOffset CreatedAt);
public sealed class GetTodosHandler
{
private readonly ITodoRepository _repo;
public GetTodosHandler(ITodoRepository repo) => _repo = repo;
public async Task<IReadOnlyList<TodoDto>> HandleAsync(Queries.GetTodosQuery query, CancellationToken ct)
{
var list = await _repo.ListAsync(ct);
// Queryは変換して返すだけ(副作用ナシ)✨
return list
.OrderByDescending(x => x.CreatedAt)
.Select(x => new TodoDto(x.Id, x.Title, x.IsCompleted, x.CreatedAt))
.ToList();
}
}
7. Presentation:Endpointは“呼ぶだけ”にする📞🧼✨
using Application.Commands;
using Application.Handlers;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
// DI(依存注入)登録
builder.Services.AddSingleton<CreateTodoHandler>();
builder.Services.AddSingleton<CompleteTodoHandler>();
builder.Services.AddSingleton<GetTodosHandler>();
// ここではInfrastructureは後で差し替えできるようにしておく想定(例:InMemoryなど)
var app = builder.Build();
app.MapPost("/todos", async (
[FromBody] CreateTodoCommand command,
CreateTodoHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(command, ct);
return result.IsSuccess
? Results.Created($"/todos/{result.Value}", new { id = result.Value })
: Results.BadRequest(result.Error);
});
app.MapPost("/todos/{id:guid}/complete", async (
Guid id,
CompleteTodoHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(new CompleteTodoCommand(id), ct);
return result.IsSuccess
? Results.NoContent()
: Results.NotFound(result.Error);
});
app.MapGet("/todos", async (
GetTodosHandler handler,
CancellationToken ct) =>
{
var list = await handler.HandleAsync(new Application.Queries.GetTodosQuery(), ct);
return Results.Ok(list);
});
app.Run();
見て〜!Endpointが「薄い」!🧼✨
- 受け取る
- handler呼ぶ
- HTTP結果に変換 これだけ🥳🎉
8. Controller版でやるとどうなる?🎮(形だけサクッと)
Controllerでも考え方は同じだよ〜!
[ApiController]
[Route("todos")]
public sealed class TodosController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateTodoCommand command,
[FromServices] CreateTodoHandler handler,
CancellationToken ct)
{
var result = await handler.HandleAsync(command, ct);
return result.IsSuccess
? Created($"/todos/{result.Value}", new { id = result.Value })
: BadRequest(result.Error);
}
}
9. ここが“実務で崩れない”ポイント集🧱✨
✅ ルール1:Handlerは「1機能だけ」🎯
- Createで「一覧取得」までやり始めたら黄色信号🚥😇
- 「それ別ハンドラにしよ?」が合言葉💗
✅ ルール2:Endpoint/Controllerに業務判断を書かない🧼
- 「タイトル空なら〜」みたいな業務チェックはHandlerへ📦✨
✅ ルール3:Queryは“更新しない”🔍🚫
- ログはOKでも、DB更新や状態変更は混ぜないのが基本🙅♀️
- どうしても必要なら「例外として明示」する(第6章の復習)📝
✅ ルール4:Repository interfaceは内側、実装は外側🧭
- 依存の向きが自然に整う✨
10. ミニ演習🧩🎀(手を動かすと一気に定着!)
演習A:GetTodoByIdHandlerを追加してみてね🔍
GetTodoByIdQuery(Guid Id)GetTodoByIdHandlerGET /todos/{id}を追加
演習B:Handlerが太り始めたら分割✂️
CreateTodoHandlerに「禁止ワードチェック」を足すとして…
- それはHandler内のprivateメソッド?
- それとも
IForbiddenWordCheckerに切り出す?🤔✨
11. AI活用(Copilot / Codex)🤖💞:事故りにくい使い方テンプレ
✅ 雛形生成プロンプト(貼ってOK)
C#/.NET 10 の Minimal API で、CQSのHandlerパターンを使います。
CreateTodoHandler / CompleteTodoHandler / GetTodosHandler を作りたいです。
- Endpointは薄く(受け取り→Handler呼び出し→HTTP変換だけ)
- Commandは Result / Result<T> で成功/失敗を返す
- Queryは DTO を返す(副作用なし)
- Repositoryは interface を Application に置く
必要なコード一式を、フォルダ構成案つきで出してください。
また「責務が混ざってないか」のレビュー観点も付けてください。
✅ レビュー質問テンプレ(AIに“監査”させる🕵️♀️✨)
- 「Endpointに業務判断が残ってない?」
- 「Queryが更新してない?」
- 「Handlerが2つ以上の責務をやってない?」
- 「Repository実装にApplicationが依存してない?」
12. よくある詰まりポイント🧱🥲(先回り)
- 「Handler増えすぎ?」 → 正常です😂✨(機能ごとに増えるのが正解)
- 「どこまでHandlerで、どこからDomain?」 → まずはHandlerでOK👌(慣れたらDomainに寄せる)
- 「DTO変換どこでやる?」 → Query HandlerでOK(Controllerに置かない)🎯
13. まとめ🎉✨
Handler化すると…
- Endpoint/Controllerが薄くなる🧼
- 1機能=1ハンドラで責務が固定される🎯
- CQSが混ざりにくくなる🧱
- 依存の向き(内側へ)を守りやすい🧭✨
次の第15章は、いよいよ「AI活用+総合演習(卒業制作🎓🎉)」! この章のHandler構造が、そのまま強い土台になるよ〜💗😊